From f5fc26ca937fb54dd43eba8723f08b9684e2a718 Mon Sep 17 00:00:00 2001 From: carddev81 Date: Thu, 5 Nov 2026 11:35:11 -0600 Subject: [PATCH 01/11] fix(migrations): give the restored cron job an id when rolling back 00058 (EN-207) --- backend/migrations/00058_drop_daily_program_tables.sql | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/migrations/00058_drop_daily_program_tables.sql b/backend/migrations/00058_drop_daily_program_tables.sql index 68aefc78a..ddf15f84e 100644 --- a/backend/migrations/00058_drop_daily_program_tables.sql +++ b/backend/migrations/00058_drop_daily_program_tables.sql @@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS public.daily_program_facility_history ( CREATE INDEX IF NOT EXISTS idx_daily_program_facility_history_history_date_program_id_facility_id ON public.daily_program_facility_history USING btree (date DESC, program_id, facility_id); -INSERT INTO public.cron_jobs (name, schedule, category, created_at, updated_at) VALUES -('daily_prog_history', '0 22 * * *', 3, NOW(), NOW()); +-- cron_jobs.id is text with no default; the app makes it a UUID. +INSERT INTO public.cron_jobs (id, name, schedule, category, created_at, updated_at) VALUES +(gen_random_uuid()::text, 'daily_prog_history', '0 22 * * *', 3, NOW(), NOW()); -- +goose StatementEnd From 41dfa73ec85024275e0c928467867d3581a3987d Mon Sep 17 00:00:00 2001 From: carddev81 Date: Fri, 6 Nov 2026 08:45:53 -0600 Subject: [PATCH 02/11] feat: ask the Programs service over HTTP and remove the module (EN-207) --- backend/.golangci.yml | 17 +- backend/cmd/main.go | 9 +- backend/go.mod | 16 +- backend/go.sum | 22 +- backend/migrations/go.mod | 10 +- backend/migrations/go.sum | 22 +- backend/seeder/go.mod | 10 +- backend/seeder/go.sum | 22 +- backend/seeder/main.go | 365 +-- backend/src/database/users.go | 15 +- backend/src/handlers/facilities_handler.go | 2 +- backend/src/handlers/internal_features.go | 2 +- backend/src/handlers/modules.go | 13 + backend/src/handlers/modules_test.go | 39 +- backend/src/handlers/server.go | 15 +- backend/src/handlers/user_handler.go | 30 +- backend/src/jasper/jasper_service.go | 2 +- backend/src/modules/learningrecord/README.md | 5 +- backend/src/modules/programs/README.md | 125 - backend/src/modules/programs/api.go | 204 -- backend/src/modules/programs/api/api.go | 195 -- backend/src/modules/programs/calendar.go | 40 - .../modules/programs/calendar_store_test.go | 386 --- .../modules/programs/canvas_classes_cache.go | 196 -- .../programs/canvas_classes_cache_test.go | 104 - .../modules/programs/canvas_events_cache.go | 175 -- .../src/modules/programs/canvas_programs.go | 2277 ----------------- .../modules/programs/canvas_programs_test.go | 785 ------ .../src/modules/programs/change_log_entry.go | 70 - .../src/modules/programs/class_enrollments.go | 231 -- .../programs/class_enrollments_store.go | 610 ----- .../programs/class_enrollments_test.go | 211 -- backend/src/modules/programs/class_event.go | 381 --- backend/src/modules/programs/class_events.go | 618 ----- .../modules/programs/class_events_store.go | 1760 ------------- .../src/modules/programs/classes_handler.go | 584 ----- .../src/modules/programs/classes_service.go | 513 ---- .../src/modules/programs/dashboard_store.go | 191 -- .../modules/programs/delete_guards_store.go | 88 - .../modules/programs/essential_ed_programs.go | 539 ---- .../programs/essential_ed_programs_test.go | 911 ------- .../programs/events_attendance_handler.go | 417 --- .../programs/events_attendance_store.go | 690 ----- .../modules/programs/facilities_handler.go | 53 - backend/src/modules/programs/handlers.go | 260 -- backend/src/modules/programs/helpers_test.go | 38 - backend/src/modules/programs/http.go | 571 ----- .../programs/instructor_conflicts_store.go | 128 - backend/src/modules/programs/jobs.go | 65 - backend/src/modules/programs/jobs_test.go | 136 - backend/src/modules/programs/live_provider.go | 306 --- .../00001_move_into_programs_schema.sql | 135 - .../src/modules/programs/migrations/README.md | 10 - backend/src/modules/programs/models.go | 298 --- backend/src/modules/programs/module.go | 173 -- backend/src/modules/programs/program.go | 267 -- .../programs/program_class_tier_handler.go | 155 -- .../src/modules/programs/program_classes.go | 487 ---- .../modules/programs/program_classes_store.go | 1385 ---------- .../modules/programs/program_classes_test.go | 63 - .../src/modules/programs/programs_handler.go | 423 --- .../programs/programs_insights_store.go | 558 ---- .../src/modules/programs/programs_store.go | 1057 -------- backend/src/modules/programs/reports.go | 738 ------ .../src/modules/programs/reports_handler.go | 590 ----- .../modules/programs/reports_handler_test.go | 52 - backend/src/modules/programs/reports_store.go | 374 --- backend/src/modules/programs/rooms_store.go | 362 --- backend/src/modules/programs/store.go | 108 - backend/src/modules/programs/users_handler.go | 258 -- backend/src/modules/programs/users_service.go | 34 - backend/src/modules/programs/users_store.go | 247 -- backend/src/modules/programs/utils.go | 54 - backend/src/modules/registry/registry.go | 42 +- backend/src/modules/registry/registry_test.go | 12 +- .../integration/attendance_reason_test.go | 171 -- .../integration/attendance_validation_test.go | 213 -- .../tests/integration/audit_tracking_test.go | 351 --- .../class_date_synchronization_test.go | 526 ---- .../integration/class_delete_cascade_test.go | 110 - .../integration/class_delete_guard_test.go | 299 --- .../integration/class_enrollments_test.go | 494 ---- .../integration/class_events_multirow_test.go | 229 -- .../class_instructor_preload_test.go | 103 - backend/tests/integration/classes_test.go | 581 ----- .../integration/conflict_detection_test.go | 100 - .../integration/enrollment_class_id_test.go | 74 - .../event_instructor_change_test.go | 85 - .../integration/facility_delete_guard_test.go | 23 +- .../facility_feature_flags_test.go | 10 +- .../integration/instructor_conflict_test.go | 165 -- .../program_completion_flag_test.go | 146 -- .../integration/program_delete_guard_test.go | 207 -- .../program_insights_engagement_test.go | 228 -- ...rogram_insights_enrollment_by_type_test.go | 233 -- .../integration/program_insights_load_test.go | 140 - .../program_insights_matrix_test.go | 97 - ...program_insights_second_enrollment_test.go | 98 - .../program_outcomes_report_test.go | 531 ---- .../integration/programs_core_reads_test.go | 81 +- .../integration/programs_core_routes_test.go | 293 +++ backend/tests/integration/programs_fake.go | 193 ++ .../integration/programs_handler_test.go | 291 --- .../tests/integration/programs_routes_test.go | 770 ------ .../tests/integration/programs_stats_test.go | 371 --- .../integration/reports_database_test.go | 243 -- backend/tests/integration/reports_test.go | 245 -- .../resident_programs_feature_test.go | 76 - .../tests/integration/room_conflict_test.go | 564 ---- .../rrule_until_integration_test.go | 262 -- .../testdata/programs/admin_calendar.golden | 129 - .../programs/admin_calendar_bad_cohort.golden | 4 - .../programs/admin_calendar_cohort.golden | 129 - .../testdata/programs/attendance_add.golden | 4 - .../programs/attendance_add_cancelled.golden | 4 - .../programs/attendance_add_future.golden | 4 - .../programs/attendance_delete.golden | 0 .../attendance_delete_not_enrolled.golden | 4 - .../programs/bulk_cancel_nothing.golden | 11 - .../programs/bulk_cancel_short_reason.golden | 4 - .../testdata/programs/canvas_schedule.golden | 19 - .../canvas_schedule_not_canvas.golden | 4 - .../testdata/programs/class_by_id.golden | 61 - .../programs/class_by_id_missing.golden | 4 - .../testdata/programs/class_create.golden | 27 - .../programs/class_create_no_program.golden | 4 - .../testdata/programs/class_update.golden | 27 - .../programs/class_update_cohort_field.golden | 4 - .../testdata/programs/classes.golden | 59 - .../programs/classes_bad_program.golden | 4 - .../programs/classes_for_program.golden | 35 - .../programs/cohort_attendance_flags.golden | 10 - .../programs/cohort_attendance_rate.golden | 4 - .../cohort_attendance_rate_canvas.golden | 6 - .../testdata/programs/cohort_by_id.golden | 186 -- .../programs/cohort_by_id_missing.golden | 4 - .../testdata/programs/cohort_canvas.golden | 59 - .../testdata/programs/cohort_create.golden | 49 - .../cohort_create_no_instructor.golden | 4 - .../cohort_create_room_conflict.golden | 21 - .../testdata/programs/cohort_delete.golden | 0 .../programs/cohort_delete_blocked.golden | 12 - .../programs/cohort_delete_canvas.golden | 4 - .../programs/cohort_delete_check.golden | 14 - .../cohort_delete_check_blocked.golden | 15 - .../testdata/programs/cohort_events.golden | 103 - .../cohort_events_after_changes.golden | 44 - .../programs/cohort_events_canvas.golden | 10 - .../testdata/programs/cohort_history.golden | 116 - .../programs/cohort_history_schedule.golden | 10 - .../programs/cohort_missing_attendance.golden | 4 - .../testdata/programs/cohort_outcomes.golden | 4 - .../testdata/programs/cohort_update.golden | 49 - .../programs/cohort_update_terminal.golden | 4 - .../testdata/programs/cohorts_update.golden | 4 - .../testdata/programs/enroll.golden | 4 - .../programs/enroll_missing_cohort.golden | 1 - .../programs/enrollment_attendance.golden | 25 - ...enrollment_attendance_after_changes.golden | 25 - .../programs/enrollment_conflicts.golden | 6 - .../programs/enrollment_conflicts_none.golden | 6 - .../testdata/programs/enrollment_date.golden | 4 - .../programs/enrollment_date_bad.golden | 4 - .../testdata/programs/enrollments.golden | 99 - .../programs/enrollments_after_changes.golden | 55 - .../enrollments_after_deactivate.golden | 99 - .../programs/enrollments_by_status.golden | 4 - .../programs/enrollments_delete.golden | 0 .../programs/enrollments_update.golden | 4 - .../enrollments_update_invalid.golden | 4 - .../testdata/programs/event_attendance.golden | 4 - .../event_attendance_cancelled.golden | 10 - .../programs/event_attendance_rate.golden | 4 - .../testdata/programs/event_create.golden | 4 - .../programs/event_create_no_rule.golden | 4 - .../programs/event_override_delete.golden | 0 .../programs/event_override_patch.golden | 4 - .../event_override_patch_no_date.golden | 4 - .../programs/event_override_uncancel.golden | 4 - .../testdata/programs/event_overrides.golden | 4 - .../programs/event_series_reschedule.golden | 4 - .../programs/event_uncancel_series.golden | 4 - .../event_uncancel_series_no_date.golden | 4 - .../testdata/programs/facilities.golden | 44 - .../programs/facility_delete_check.golden | 17 - .../programs/facility_instructors.golden | 33 - .../programs/historical_enrollment.golden | 7 - .../historical_enrollment_no_dates.golden | 4 - .../programs/instructor_classes.golden | 45 - .../instructor_classes_no_dates.golden | 4 - .../programs/missing_attendance.golden | 10 - .../programs/program_archive_check.golden | 9 - .../testdata/programs/program_by_id.golden | 65 - .../programs/program_by_id_bad_id.golden | 4 - .../programs/program_by_id_missing.golden | 4 - .../testdata/programs/program_canvas.golden | 29 - .../testdata/programs/program_classes.golden | 272 -- .../program_classes_after_deletes.golden | 413 --- .../program_classes_all_facilities.golden | 290 --- .../testdata/programs/program_cohorts.golden | 4 - .../programs/program_cohorts_canvas.golden | 46 - .../programs/program_cohorts_none.golden | 10 - .../programs/program_completions.golden | 47 - .../programs/program_completions_self.golden | 47 - .../testdata/programs/program_create.golden | 32 - .../programs/program_create_bad_body.golden | 4 - .../programs/program_create_duplicate.golden | 4 - .../program_create_missing_name.golden | 4 - .../testdata/programs/program_delete.golden | 0 .../programs/program_delete_blocked.golden | 11 - .../programs/program_delete_check.golden | 14 - .../testdata/programs/program_filters.golden | 28 - .../testdata/programs/program_history.golden | 86 - .../programs/program_history_status.golden | 26 - .../program_status_archive_blocked.golden | 11 - .../programs/program_status_inactive.golden | 8 - .../programs/program_status_missing.golden | 4 - .../testdata/programs/program_update.golden | 44 - .../programs/program_update_missing.golden | 4 - .../testdata/programs/programs.golden | 174 -- .../programs/programs_after_changes.golden | 174 -- .../testdata/programs/programs_csv.golden | 4 - .../programs/programs_detailed_list.golden | 4 - .../testdata/programs/programs_paged.golden | 93 - .../testdata/programs/programs_search.golden | 52 - .../programs/programs_stats_all.golden | 10 - .../programs/programs_stats_facility.golden | 10 - .../testdata/programs/report_bad_type.golden | 4 - .../programs/report_class_roster.golden | 5 - .../report_comparison_facility_admin.golden | 4 - .../programs/report_program_outcomes.golden | 3 - .../programs/report_resident_profile.golden | 2 - .../programs/report_roster_no_cohort.golden | 4 - .../testdata/programs/resident_verify.golden | 27 - .../testdata/programs/room_create.golden | 12 - .../programs/room_create_no_name.golden | 4 - .../testdata/programs/rooms.golden | 23 - .../testdata/programs/student_calendar.golden | 129 - .../student_calendar_bad_range.golden | 4 - .../testdata/programs/todays_schedule.golden | 10 - .../programs/user_attendance_export.golden | 2 - .../programs/user_attendance_trend.golden | 4 - .../testdata/programs/user_programs.golden | 4 - .../enrollments_after_transfer.golden | 33 - .../welding_enrollments_after_transfer.golden | 33 - .../account_history_after_deactivate.golden | 0 .../account_history_attendance.golden | 0 .../dashboard_class_metrics.golden | 0 .../dashboard_facility_health.golden | 0 .../metrics_completion_matrix.golden | 0 .../metrics_engagement.golden | 0 .../metrics_enrollment_by_type.golden | 0 .../metrics_load_distribution.golden | 0 .../metrics_second_enrollment.golden | 0 .../user_deactivate.golden | 0 .../users_bulk_deactivate.golden | 0 .../users_include_bad_cohort.golden | 0 .../users_only_enrolled.golden | 0 .../users_only_unenrolled.golden | 0 backend/tests/integration/testenv.go | 304 +-- .../user_attendance_export_test.go | 387 --- go.work.sum | 2 + 262 files changed, 662 insertions(+), 35173 deletions(-) delete mode 100644 backend/src/modules/programs/README.md delete mode 100644 backend/src/modules/programs/api.go delete mode 100644 backend/src/modules/programs/api/api.go delete mode 100644 backend/src/modules/programs/calendar.go delete mode 100644 backend/src/modules/programs/calendar_store_test.go delete mode 100644 backend/src/modules/programs/canvas_classes_cache.go delete mode 100644 backend/src/modules/programs/canvas_classes_cache_test.go delete mode 100644 backend/src/modules/programs/canvas_events_cache.go delete mode 100644 backend/src/modules/programs/canvas_programs.go delete mode 100644 backend/src/modules/programs/canvas_programs_test.go delete mode 100644 backend/src/modules/programs/change_log_entry.go delete mode 100644 backend/src/modules/programs/class_enrollments.go delete mode 100644 backend/src/modules/programs/class_enrollments_store.go delete mode 100644 backend/src/modules/programs/class_enrollments_test.go delete mode 100644 backend/src/modules/programs/class_event.go delete mode 100644 backend/src/modules/programs/class_events.go delete mode 100644 backend/src/modules/programs/class_events_store.go delete mode 100644 backend/src/modules/programs/classes_handler.go delete mode 100644 backend/src/modules/programs/classes_service.go delete mode 100644 backend/src/modules/programs/dashboard_store.go delete mode 100644 backend/src/modules/programs/delete_guards_store.go delete mode 100644 backend/src/modules/programs/essential_ed_programs.go delete mode 100644 backend/src/modules/programs/essential_ed_programs_test.go delete mode 100644 backend/src/modules/programs/events_attendance_handler.go delete mode 100644 backend/src/modules/programs/events_attendance_store.go delete mode 100644 backend/src/modules/programs/facilities_handler.go delete mode 100644 backend/src/modules/programs/handlers.go delete mode 100644 backend/src/modules/programs/helpers_test.go delete mode 100644 backend/src/modules/programs/http.go delete mode 100644 backend/src/modules/programs/instructor_conflicts_store.go delete mode 100644 backend/src/modules/programs/jobs.go delete mode 100644 backend/src/modules/programs/jobs_test.go delete mode 100644 backend/src/modules/programs/live_provider.go delete mode 100644 backend/src/modules/programs/migrations/00001_move_into_programs_schema.sql delete mode 100644 backend/src/modules/programs/migrations/README.md delete mode 100644 backend/src/modules/programs/models.go delete mode 100644 backend/src/modules/programs/module.go delete mode 100644 backend/src/modules/programs/program.go delete mode 100644 backend/src/modules/programs/program_class_tier_handler.go delete mode 100644 backend/src/modules/programs/program_classes.go delete mode 100644 backend/src/modules/programs/program_classes_store.go delete mode 100644 backend/src/modules/programs/program_classes_test.go delete mode 100644 backend/src/modules/programs/programs_handler.go delete mode 100644 backend/src/modules/programs/programs_insights_store.go delete mode 100644 backend/src/modules/programs/programs_store.go delete mode 100644 backend/src/modules/programs/reports.go delete mode 100644 backend/src/modules/programs/reports_handler.go delete mode 100644 backend/src/modules/programs/reports_handler_test.go delete mode 100644 backend/src/modules/programs/reports_store.go delete mode 100644 backend/src/modules/programs/rooms_store.go delete mode 100644 backend/src/modules/programs/store.go delete mode 100644 backend/src/modules/programs/users_handler.go delete mode 100644 backend/src/modules/programs/users_service.go delete mode 100644 backend/src/modules/programs/users_store.go delete mode 100644 backend/src/modules/programs/utils.go delete mode 100644 backend/tests/integration/attendance_reason_test.go delete mode 100644 backend/tests/integration/attendance_validation_test.go delete mode 100644 backend/tests/integration/audit_tracking_test.go delete mode 100644 backend/tests/integration/class_date_synchronization_test.go delete mode 100644 backend/tests/integration/class_delete_cascade_test.go delete mode 100644 backend/tests/integration/class_delete_guard_test.go delete mode 100644 backend/tests/integration/class_enrollments_test.go delete mode 100644 backend/tests/integration/class_events_multirow_test.go delete mode 100644 backend/tests/integration/class_instructor_preload_test.go delete mode 100644 backend/tests/integration/classes_test.go delete mode 100644 backend/tests/integration/conflict_detection_test.go delete mode 100644 backend/tests/integration/enrollment_class_id_test.go delete mode 100644 backend/tests/integration/event_instructor_change_test.go delete mode 100644 backend/tests/integration/instructor_conflict_test.go delete mode 100644 backend/tests/integration/program_completion_flag_test.go delete mode 100644 backend/tests/integration/program_delete_guard_test.go delete mode 100644 backend/tests/integration/program_insights_engagement_test.go delete mode 100644 backend/tests/integration/program_insights_enrollment_by_type_test.go delete mode 100644 backend/tests/integration/program_insights_load_test.go delete mode 100644 backend/tests/integration/program_insights_matrix_test.go delete mode 100644 backend/tests/integration/program_insights_second_enrollment_test.go delete mode 100644 backend/tests/integration/program_outcomes_report_test.go create mode 100644 backend/tests/integration/programs_core_routes_test.go create mode 100644 backend/tests/integration/programs_fake.go delete mode 100644 backend/tests/integration/programs_handler_test.go delete mode 100644 backend/tests/integration/programs_routes_test.go delete mode 100644 backend/tests/integration/programs_stats_test.go delete mode 100644 backend/tests/integration/reports_database_test.go delete mode 100644 backend/tests/integration/reports_test.go delete mode 100644 backend/tests/integration/room_conflict_test.go delete mode 100644 backend/tests/integration/rrule_until_integration_test.go delete mode 100644 backend/tests/integration/testdata/programs/admin_calendar.golden delete mode 100644 backend/tests/integration/testdata/programs/admin_calendar_bad_cohort.golden delete mode 100644 backend/tests/integration/testdata/programs/admin_calendar_cohort.golden delete mode 100644 backend/tests/integration/testdata/programs/attendance_add.golden delete mode 100644 backend/tests/integration/testdata/programs/attendance_add_cancelled.golden delete mode 100644 backend/tests/integration/testdata/programs/attendance_add_future.golden delete mode 100644 backend/tests/integration/testdata/programs/attendance_delete.golden delete mode 100644 backend/tests/integration/testdata/programs/attendance_delete_not_enrolled.golden delete mode 100644 backend/tests/integration/testdata/programs/bulk_cancel_nothing.golden delete mode 100644 backend/tests/integration/testdata/programs/bulk_cancel_short_reason.golden delete mode 100644 backend/tests/integration/testdata/programs/canvas_schedule.golden delete mode 100644 backend/tests/integration/testdata/programs/canvas_schedule_not_canvas.golden delete mode 100644 backend/tests/integration/testdata/programs/class_by_id.golden delete mode 100644 backend/tests/integration/testdata/programs/class_by_id_missing.golden delete mode 100644 backend/tests/integration/testdata/programs/class_create.golden delete mode 100644 backend/tests/integration/testdata/programs/class_create_no_program.golden delete mode 100644 backend/tests/integration/testdata/programs/class_update.golden delete mode 100644 backend/tests/integration/testdata/programs/class_update_cohort_field.golden delete mode 100644 backend/tests/integration/testdata/programs/classes.golden delete mode 100644 backend/tests/integration/testdata/programs/classes_bad_program.golden delete mode 100644 backend/tests/integration/testdata/programs/classes_for_program.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_attendance_flags.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_attendance_rate.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_attendance_rate_canvas.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_by_id.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_by_id_missing.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_canvas.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_create.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_create_no_instructor.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_create_room_conflict.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_delete.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_delete_blocked.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_delete_canvas.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_delete_check.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_delete_check_blocked.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_events.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_events_after_changes.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_events_canvas.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_history.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_history_schedule.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_missing_attendance.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_outcomes.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_update.golden delete mode 100644 backend/tests/integration/testdata/programs/cohort_update_terminal.golden delete mode 100644 backend/tests/integration/testdata/programs/cohorts_update.golden delete mode 100644 backend/tests/integration/testdata/programs/enroll.golden delete mode 100644 backend/tests/integration/testdata/programs/enroll_missing_cohort.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollment_attendance.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollment_attendance_after_changes.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollment_conflicts.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollment_conflicts_none.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollment_date.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollment_date_bad.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollments.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollments_after_changes.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollments_after_deactivate.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollments_by_status.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollments_delete.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollments_update.golden delete mode 100644 backend/tests/integration/testdata/programs/enrollments_update_invalid.golden delete mode 100644 backend/tests/integration/testdata/programs/event_attendance.golden delete mode 100644 backend/tests/integration/testdata/programs/event_attendance_cancelled.golden delete mode 100644 backend/tests/integration/testdata/programs/event_attendance_rate.golden delete mode 100644 backend/tests/integration/testdata/programs/event_create.golden delete mode 100644 backend/tests/integration/testdata/programs/event_create_no_rule.golden delete mode 100644 backend/tests/integration/testdata/programs/event_override_delete.golden delete mode 100644 backend/tests/integration/testdata/programs/event_override_patch.golden delete mode 100644 backend/tests/integration/testdata/programs/event_override_patch_no_date.golden delete mode 100644 backend/tests/integration/testdata/programs/event_override_uncancel.golden delete mode 100644 backend/tests/integration/testdata/programs/event_overrides.golden delete mode 100644 backend/tests/integration/testdata/programs/event_series_reschedule.golden delete mode 100644 backend/tests/integration/testdata/programs/event_uncancel_series.golden delete mode 100644 backend/tests/integration/testdata/programs/event_uncancel_series_no_date.golden delete mode 100644 backend/tests/integration/testdata/programs/facilities.golden delete mode 100644 backend/tests/integration/testdata/programs/facility_delete_check.golden delete mode 100644 backend/tests/integration/testdata/programs/facility_instructors.golden delete mode 100644 backend/tests/integration/testdata/programs/historical_enrollment.golden delete mode 100644 backend/tests/integration/testdata/programs/historical_enrollment_no_dates.golden delete mode 100644 backend/tests/integration/testdata/programs/instructor_classes.golden delete mode 100644 backend/tests/integration/testdata/programs/instructor_classes_no_dates.golden delete mode 100644 backend/tests/integration/testdata/programs/missing_attendance.golden delete mode 100644 backend/tests/integration/testdata/programs/program_archive_check.golden delete mode 100644 backend/tests/integration/testdata/programs/program_by_id.golden delete mode 100644 backend/tests/integration/testdata/programs/program_by_id_bad_id.golden delete mode 100644 backend/tests/integration/testdata/programs/program_by_id_missing.golden delete mode 100644 backend/tests/integration/testdata/programs/program_canvas.golden delete mode 100644 backend/tests/integration/testdata/programs/program_classes.golden delete mode 100644 backend/tests/integration/testdata/programs/program_classes_after_deletes.golden delete mode 100644 backend/tests/integration/testdata/programs/program_classes_all_facilities.golden delete mode 100644 backend/tests/integration/testdata/programs/program_cohorts.golden delete mode 100644 backend/tests/integration/testdata/programs/program_cohorts_canvas.golden delete mode 100644 backend/tests/integration/testdata/programs/program_cohorts_none.golden delete mode 100644 backend/tests/integration/testdata/programs/program_completions.golden delete mode 100644 backend/tests/integration/testdata/programs/program_completions_self.golden delete mode 100644 backend/tests/integration/testdata/programs/program_create.golden delete mode 100644 backend/tests/integration/testdata/programs/program_create_bad_body.golden delete mode 100644 backend/tests/integration/testdata/programs/program_create_duplicate.golden delete mode 100644 backend/tests/integration/testdata/programs/program_create_missing_name.golden delete mode 100644 backend/tests/integration/testdata/programs/program_delete.golden delete mode 100644 backend/tests/integration/testdata/programs/program_delete_blocked.golden delete mode 100644 backend/tests/integration/testdata/programs/program_delete_check.golden delete mode 100644 backend/tests/integration/testdata/programs/program_filters.golden delete mode 100644 backend/tests/integration/testdata/programs/program_history.golden delete mode 100644 backend/tests/integration/testdata/programs/program_history_status.golden delete mode 100644 backend/tests/integration/testdata/programs/program_status_archive_blocked.golden delete mode 100644 backend/tests/integration/testdata/programs/program_status_inactive.golden delete mode 100644 backend/tests/integration/testdata/programs/program_status_missing.golden delete mode 100644 backend/tests/integration/testdata/programs/program_update.golden delete mode 100644 backend/tests/integration/testdata/programs/program_update_missing.golden delete mode 100644 backend/tests/integration/testdata/programs/programs.golden delete mode 100644 backend/tests/integration/testdata/programs/programs_after_changes.golden delete mode 100644 backend/tests/integration/testdata/programs/programs_csv.golden delete mode 100644 backend/tests/integration/testdata/programs/programs_detailed_list.golden delete mode 100644 backend/tests/integration/testdata/programs/programs_paged.golden delete mode 100644 backend/tests/integration/testdata/programs/programs_search.golden delete mode 100644 backend/tests/integration/testdata/programs/programs_stats_all.golden delete mode 100644 backend/tests/integration/testdata/programs/programs_stats_facility.golden delete mode 100644 backend/tests/integration/testdata/programs/report_bad_type.golden delete mode 100644 backend/tests/integration/testdata/programs/report_class_roster.golden delete mode 100644 backend/tests/integration/testdata/programs/report_comparison_facility_admin.golden delete mode 100644 backend/tests/integration/testdata/programs/report_program_outcomes.golden delete mode 100644 backend/tests/integration/testdata/programs/report_resident_profile.golden delete mode 100644 backend/tests/integration/testdata/programs/report_roster_no_cohort.golden delete mode 100644 backend/tests/integration/testdata/programs/resident_verify.golden delete mode 100644 backend/tests/integration/testdata/programs/room_create.golden delete mode 100644 backend/tests/integration/testdata/programs/room_create_no_name.golden delete mode 100644 backend/tests/integration/testdata/programs/rooms.golden delete mode 100644 backend/tests/integration/testdata/programs/student_calendar.golden delete mode 100644 backend/tests/integration/testdata/programs/student_calendar_bad_range.golden delete mode 100644 backend/tests/integration/testdata/programs/todays_schedule.golden delete mode 100644 backend/tests/integration/testdata/programs/user_attendance_export.golden delete mode 100644 backend/tests/integration/testdata/programs/user_attendance_trend.golden delete mode 100644 backend/tests/integration/testdata/programs/user_programs.golden delete mode 100644 backend/tests/integration/testdata/programs_core_reads/enrollments_after_transfer.golden delete mode 100644 backend/tests/integration/testdata/programs_core_reads/welding_enrollments_after_transfer.golden rename backend/tests/integration/testdata/{programs => programs_core_routes}/account_history_after_deactivate.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/account_history_attendance.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/dashboard_class_metrics.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/dashboard_facility_health.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/metrics_completion_matrix.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/metrics_engagement.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/metrics_enrollment_by_type.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/metrics_load_distribution.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/metrics_second_enrollment.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/user_deactivate.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/users_bulk_deactivate.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/users_include_bad_cohort.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/users_only_enrolled.golden (100%) rename backend/tests/integration/testdata/{programs => programs_core_routes}/users_only_unenrolled.golden (100%) delete mode 100644 backend/tests/integration/user_attendance_export_test.go diff --git a/backend/.golangci.yml b/backend/.golangci.yml index f9e7670ed..b1aff2b57 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -1,8 +1,9 @@ version: "2" # golangci-lint's standard linters (what CI ran before this file existed) plus -# depguard for the feature-module boundaries (EN-190). Open Content and Provider -# Platforms aren't here: they are their own services (EN-205, EN-206). +# depguard for the feature-module boundaries (EN-190). Open Content, Provider +# Platforms and Programs aren't here: they are their own services (EN-205, +# EN-206, EN-207). linters: default: standard enable: @@ -29,7 +30,6 @@ linters: allow: - github.com/UnlockedLabs/unlocked-core/backend/src/modules/module - github.com/UnlockedLabs/unlocked-core/backend/src/modules/registry - - github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api - github.com/UnlockedLabs/unlocked-core/backend/src/modules/learningrecord/api # The seam imports nothing from core. depguard only sees each file's # own imports, so a core type handed to modules through the seam @@ -42,16 +42,6 @@ linters: desc: the module seam must not expose core types to feature modules (EN-188) allow: - github.com/UnlockedLabs/unlocked-core/backend/src/modules/module - programs: - list-mode: lax - files: ["**/src/modules/programs/**"] - deny: - - pkg: github.com/UnlockedLabs/unlocked-core/backend - desc: a feature module reaches core only through src/modules/module and unlocked-contracts, and other modules only through their api package (EN-186, EN-188) - allow: - - github.com/UnlockedLabs/unlocked-core/backend/src/modules/module - - github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs - - github.com/UnlockedLabs/unlocked-core/backend/src/modules/learningrecord/api learningrecord: list-mode: lax files: ["**/src/modules/learningrecord/**"] @@ -61,4 +51,3 @@ linters: allow: - github.com/UnlockedLabs/unlocked-core/backend/src/modules/module - github.com/UnlockedLabs/unlocked-core/backend/src/modules/learningrecord - - github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 00782d4a3..aca272932 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -50,12 +50,15 @@ func main() { return } } - // The API server asks Open Content its questions, which it answers only - // with the site's secret. The dev seeder builds a server too, but never - // asks, so the check is here rather than in NewServer. + // The API server asks Open Content and Programs their questions, which + // they answer only with the site's secrets. The dev seeder builds a server + // too, but never asks, so the check is here rather than in NewServer. if !testing && os.Getenv("OPEN_CONTENT_TOKEN") == "" { log.Fatal("OPEN_CONTENT_TOKEN must be set: it's the secret the server asks Open Content's questions with") } + if !testing && os.Getenv("PROGRAMS_TOKEN") == "" { + log.Fatal("PROGRAMS_TOKEN must be set: it's the secret the server asks Programs' questions with") + } if !testing { requireProviderPlatformsToken() } diff --git a/backend/go.mod b/backend/go.mod index faa932db2..b0ee6cdd8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,25 +17,13 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 - github.com/teambition/rrule-go v1.8.2 - golang.org/x/net v0.60.0 // indirect gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) -require ( - github.com/UnlockedLabs/unlocked-contracts v0.4.0 - github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94 -) +require github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6 -require ( - github.com/richardlehane/mscfb v1.0.8 // indirect - github.com/richardlehane/msoleps v1.0.6 // indirect - github.com/tiendc/go-deepcopy v1.7.2 // indirect - github.com/xuri/efp v0.0.1 // indirect - github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect -) +require go.yaml.in/yaml/v2 v2.4.3 // indirect require ( github.com/beorn7/perks v1.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 04e08d3cf..f4181f285 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,5 @@ -github.com/UnlockedLabs/unlocked-contracts v0.4.0 h1:289y2TWKGdi1S6/DHlzxark3wZJICdEggLoyXiPfWQ4= -github.com/UnlockedLabs/unlocked-contracts v0.4.0/go.mod h1:RAEqqBigq/Gu5ARxWKsSjN34B2N3GlEyGX8DjHjKxK0= +github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6 h1:iozETpI7eV199CO9s5y4g6jswUPuM6mK4Xq/crXr9DY= +github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6/go.mod h1:RAEqqBigq/Gu5ARxWKsSjN34B2N3GlEyGX8DjHjKxK0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -93,10 +93,6 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/richardlehane/mscfb v1.0.8 h1:UXdg61fxF69/X9yMYuRHAWSrGXIul/UAPivAsUXMme8= -github.com/richardlehane/mscfb v1.0.8/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= -github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= -github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -110,16 +106,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= -github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= -github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= -github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= -github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= -github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94 h1:yPdJeJLC1uTN8zQBHE2nqHjZ15PfxU9xsX75vNcyy/c= -github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94/go.mod h1:WnwweQUkWv/3Y6OBVy0bgqN0zHAbn2N+i2zPUAXF1SI= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -130,12 +116,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/migrations/go.mod b/backend/migrations/go.mod index 6fff2cf18..d47b886b1 100644 --- a/backend/migrations/go.mod +++ b/backend/migrations/go.mod @@ -14,7 +14,7 @@ require ( ) require ( - github.com/UnlockedLabs/unlocked-contracts v0.4.0 // indirect + github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect @@ -36,19 +36,11 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/richardlehane/mscfb v1.0.8 // indirect - github.com/richardlehane/msoleps v1.0.6 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/teambition/rrule-go v1.8.2 // indirect - github.com/tiendc/go-deepcopy v1.7.2 // indirect - github.com/xuri/efp v0.0.1 // indirect - github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94 // indirect - github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/net v0.60.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/backend/migrations/go.sum b/backend/migrations/go.sum index 4c976e3be..7dcf57f38 100644 --- a/backend/migrations/go.sum +++ b/backend/migrations/go.sum @@ -1,5 +1,5 @@ -github.com/UnlockedLabs/unlocked-contracts v0.4.0 h1:289y2TWKGdi1S6/DHlzxark3wZJICdEggLoyXiPfWQ4= -github.com/UnlockedLabs/unlocked-contracts v0.4.0/go.mod h1:RAEqqBigq/Gu5ARxWKsSjN34B2N3GlEyGX8DjHjKxK0= +github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6 h1:iozETpI7eV199CO9s5y4g6jswUPuM6mK4Xq/crXr9DY= +github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6/go.mod h1:RAEqqBigq/Gu5ARxWKsSjN34B2N3GlEyGX8DjHjKxK0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -63,10 +63,6 @@ github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jp github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/richardlehane/mscfb v1.0.8 h1:UXdg61fxF69/X9yMYuRHAWSrGXIul/UAPivAsUXMme8= -github.com/richardlehane/mscfb v1.0.8/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= -github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= -github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -76,28 +72,14 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= -github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= -github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= -github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= -github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= -github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94 h1:yPdJeJLC1uTN8zQBHE2nqHjZ15PfxU9xsX75vNcyy/c= -github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94/go.mod h1:WnwweQUkWv/3Y6OBVy0bgqN0zHAbn2N+i2zPUAXF1SI= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/seeder/go.mod b/backend/seeder/go.mod index 6619236bd..041d5fcd2 100644 --- a/backend/seeder/go.mod +++ b/backend/seeder/go.mod @@ -3,16 +3,15 @@ module seeder go 1.26.10 require ( - github.com/UnlockedLabs/unlocked-contracts v0.4.0 github.com/UnlockedLabs/unlocked-core/backend v0.0.0 github.com/go-faker/faker/v4 v4.6.0 github.com/joho/godotenv v1.5.1 - github.com/teambition/rrule-go v1.8.2 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) require ( + github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.14 // indirect @@ -49,20 +48,13 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/richardlehane/mscfb v1.0.8 // indirect - github.com/richardlehane/msoleps v1.0.6 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/tiendc/go-deepcopy v1.7.2 // indirect - github.com/xuri/efp v0.0.1 // indirect - github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94 // indirect - github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/net v0.60.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/backend/seeder/go.sum b/backend/seeder/go.sum index dfb0a1851..f40f70adf 100644 --- a/backend/seeder/go.sum +++ b/backend/seeder/go.sum @@ -1,5 +1,5 @@ -github.com/UnlockedLabs/unlocked-contracts v0.4.0 h1:289y2TWKGdi1S6/DHlzxark3wZJICdEggLoyXiPfWQ4= -github.com/UnlockedLabs/unlocked-contracts v0.4.0/go.mod h1:RAEqqBigq/Gu5ARxWKsSjN34B2N3GlEyGX8DjHjKxK0= +github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6 h1:iozETpI7eV199CO9s5y4g6jswUPuM6mK4Xq/crXr9DY= +github.com/UnlockedLabs/unlocked-contracts v0.4.1-0.20261105200604-ce44538defe6/go.mod h1:RAEqqBigq/Gu5ARxWKsSjN34B2N3GlEyGX8DjHjKxK0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -95,10 +95,6 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/richardlehane/mscfb v1.0.8 h1:UXdg61fxF69/X9yMYuRHAWSrGXIul/UAPivAsUXMme8= -github.com/richardlehane/mscfb v1.0.8/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= -github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= -github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -112,16 +108,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= -github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= -github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= -github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= -github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= -github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94 h1:yPdJeJLC1uTN8zQBHE2nqHjZ15PfxU9xsX75vNcyy/c= -github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94/go.mod h1:WnwweQUkWv/3Y6OBVy0bgqN0zHAbn2N+i2zPUAXF1SI= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= -github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -132,12 +118,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/seeder/main.go b/backend/seeder/main.go index d946ca985..47a07af28 100644 --- a/backend/seeder/main.go +++ b/backend/seeder/main.go @@ -13,30 +13,19 @@ import ( "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" "github.com/UnlockedLabs/unlocked-core/backend/src/models" "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" "github.com/UnlockedLabs/unlocked-core/backend/src/modules/registry" - "github.com/UnlockedLabs/unlocked-contracts/identity" "github.com/go-faker/faker/v4" - "github.com/go-faker/faker/v4/pkg/options" "github.com/joho/godotenv" - "github.com/teambition/rrule-go" "gorm.io/driver/postgres" "gorm.io/gorm" ) const ( - DaysInPast = 90 - MonthsInFuture = 6 SystemBatchID = 99999 SystemBatchUID = "system_batch" ) -var ( - startDate = time.Now().AddDate(0, 0, -DaysInPast) - endDate = time.Now().AddDate(0, MonthsInFuture, 0) -) - func main() { err := godotenv.Load() if err != nil { @@ -69,10 +58,8 @@ func seedTestData(db *gorm.DB) { if err := db.Where("username = ?", SystemBatchUID).First(&systemUser).Error; err == nil { systemUserID = systemUser.ID } - // Core's rows read the user from models.UserIDKey, the modules' from - // identity.Claims. - ctx := context.WithValue(context.Background(), models.UserIDKey, systemUserID) - db = db.WithContext(identity.NewContext(ctx, &identity.Claims{UserID: systemUserID})) + // Core's rows read the user who created them from models.UserIDKey. + db = db.WithContext(context.WithValue(context.Background(), models.UserIDKey, systemUserID)) facilities := []models.Facility{ { @@ -108,139 +95,6 @@ func seedTestData(db *gorm.DB) { // exist, and GetUnmappedUsers then hides them from the list you would use to link // them for real. Link users by hand against a live provider instead. } - cohorts := []programs.ProgramClassCohort{} - cohorts, err = createFacilityPrograms(db) - if err != nil { - log.Printf("Failed to create facility programs: %v", err) - } - events := []programs.ProgramClassEvent{} - if err := db.Find(&events).Error; err != nil { - log.Fatalf("Failed to get events from db") - } - // class_completions.admin_email is NOT NULL and every certificate screen shows it, so - // resolve a real granting admin once. generateFakeUsers only makes students, so this - // comes from the accounts created during initial setup. - adminEmail := "admin@unlocked.test" - var seededAdminEmail string - if err := db.Model(&models.User{}). - Where("role IN ?", models.AdminRoles). - Order("id"). - Limit(1). - Pluck("email", &seededAdminEmail).Error; err == nil && seededAdminEmail != "" { - adminEmail = seededAdminEmail - } - for _, user := range users { - statuses := [6]programs.ProgramEnrollmentStatus{ - programs.EnrollmentCancelled, - programs.EnrollmentCompleted, - programs.EnrollmentIncompleteWithdrawn, - programs.EnrollmentIncompleteDropped, - programs.EnrollmentIncompleteFailedToComplete, - programs.EnrollmentIncompleteTransfered, - } - - numEnrollments := rand.Intn(3) + 1 - availableCohorts := []programs.ProgramClassCohort{} - for i := range cohorts { - if cohorts[i].FacilityID == user.FacilityID { - availableCohorts = append(availableCohorts, cohorts[i]) - } - } - rand.Shuffle(len(availableCohorts), func(i, j int) { - availableCohorts[i], availableCohorts[j] = availableCohorts[j], availableCohorts[i] - }) - seenClasses := make(map[uint]bool, len(availableCohorts)) - sampled := make([]programs.ProgramClassCohort, 0, numEnrollments) - for _, cohort := range availableCohorts { - if seenClasses[cohort.ClassID] { - continue - } - seenClasses[cohort.ClassID] = true - sampled = append(sampled, cohort) - if len(sampled) == numEnrollments { - break - } - } - for _, cohort := range sampled { - status := programs.Enrolled - if rand.Intn(100)%2 == 0 { - status = statuses[rand.Intn(len(statuses))] - } - // CohortID is what a resident enrolls in; the BeforeCreate hook denormalizes - // the parent ClassID from it. Setting ClassID here would store a COHORT id - // in the class column -- it compiles, and it corrupts the class rollups. - enrollment := programs.ProgramClassEnrollment{ - UserID: user.ID, - CohortID: cohort.ID, - EnrollmentStatus: status, - } - if err := db.Create(&enrollment).Error; err != nil { - log.Printf("Failed to create enrollment: %v", err) - } - if status == programs.EnrollmentCompleted { - cohortID, classID, programID := cohort.ID, cohort.ClassID, cohort.ProgramID - // The enrollment hook only sets enrolled_at for an Active cohort; seeded - // cohorts are Scheduled, so fall back to the cohort's start date rather - // than leaving the zero time (which renders as year 0001). - enrolledOn := cohort.StartDt - if enrollment.EnrolledAt != nil { - enrolledOn = *enrollment.EnrolledAt - } - completion := programs.ClassCompletion{ - UserID: user.ID, - CohortID: &cohortID, - ClassID: &classID, - ProgramID: &programID, - ProgramName: faker.Sentence(options.WithRandomStringLength(16)), - FacilityName: facilities[user.FacilityID-1].Name, - ProgramOwner: faker.Name(), - ClassName: cohort.ClassName, - CreditType: string(resolveCreditType(db, classID, programID)), - AdminEmail: adminEmail, - CohortStartDt: cohort.StartDt, - EnrolledOnDt: enrolledOn, - } - if err := db.Create(&completion).Error; err != nil { - log.Printf("Failed to create completion") - } - } - log.Printf("Creating program enrollment for user %s", user.Username) - } - attendanceStatus := []programs.Attendance{ - programs.Present, - programs.Absent_Excused, - programs.Absent_Unexcused, - } - for _, event := range events { - rule, err := rrule.StrToRRule(event.RecurrenceRule) - if err != nil { - log.Printf("Failed to parse rrule for event %d: %v", event.ID, err) - continue - } - occurrences := rule.Between(startDate, endDate, true) - for _, occ := range occurrences { - if rand.Intn(10)%2 == 0 { - continue - } - attendanceDate := occ.Format("2006-01-02") - attendance := programs.ProgramClassEventAttendance{ - EventID: event.ID, - UserID: user.ID, - Date: attendanceDate, - AttendanceStatus: attendanceStatus[rand.Intn(len(attendanceStatus))], - } - - result := db.Where(attendance).FirstOrCreate(&attendance) - if result.Error != nil { - log.Printf("Failed to create attendance for user %d on %s: %v", user.ID, attendance.Date, result.Error) - } else if result.RowsAffected == 0 { - log.Printf("Attendance already exists for user %d on %s for event %d. Skipping.", user.ID, attendanceDate, event.ID) - } else { - log.Printf("Created attendance for user %s at event: %d on %s", user.Username, event.ID, attendanceDate) - } - } - } - } createUserSessionActivity(db, users) } @@ -270,221 +124,6 @@ func createUserSessionActivity(db *gorm.DB, dbUsers []models.User) { } } -func getRandomProgram(programMap map[string]programs.ProgType) string { - keySlice := make([]string, 0, len(programMap)) - for key := range programMap { - keySlice = append(keySlice, key) - } - return keySlice[rand.Intn(len(keySlice))] -} - -func createFacilityPrograms(db *gorm.DB) ([]programs.ProgramClassCohort, error) { - facilities := []models.Facility{} - fundingTypes := [6]programs.FundingType{programs.EduGrants, programs.FederalGrants, programs.InmateWelfare, programs.NonProfitOrgs, programs.Other, programs.StateGrants} - creditTypes := [4]programs.CreditType{programs.Completion, programs.EarnedTime, programs.Education, programs.Participation} - programMap := map[string]programs.ProgType{ - "Anger Management": programs.Therapeutic, - "Substance Abuse Treatment": programs.MentalHealth, - "AA/NA": programs.MentalHealth, - "Thinking for a Change": programs.LifeSkills, - "A New Freedom": programs.LifeSkills, - "Dog Training": programs.Vocational, - "A New Path": programs.Therapeutic, - "GED/Hi-SET": programs.Educational, - "Parenting": programs.LifeSkills, - "Employment": programs.Vocational, - "Life Skills": programs.LifeSkills, - "Health and Wellness": programs.MentalHealth, - "Financial Literacy": programs.Educational, - "Computer Skills": programs.Educational, - } - programClassDescriptions := map[string]string{ - "Anger Management": "Techniques to control and express anger constructively.", - "Substance Abuse Treatment": "Support and strategies for overcoming addiction.", - "AA/NA": "Peer support groups for alcohol and drug recovery.", - "Thinking for a Change": "Cognitive-based program for decision-making and behavior change.", - "A New Freedom": "Life skills and personal growth program for better choices.", - "Dog Training": "Hands-on program teaching dog training and care skills.", - "A New Path": "Therapeutic program focused on personal healing and growth.", - "GED/Hi-SET": "Education program to earn a high school equivalency diploma.", - "Parenting": "Guidance and skills for effective parenting strategies.", - "Employment": "Job readiness, resume building, and interview preparation.", - "Life Skills": "Essential skills for daily living and self-sufficiency.", - "Health and Wellness": "Education on physical and mental well-being practices.", - "Financial Literacy": "Budgeting, saving, and money management fundamentals.", - "Computer Skills": "Basic to advanced computer literacy and digital skills.", - } - if err := db.Find(&facilities).Error; err != nil { - return nil, err - } - facilityRooms := make(map[uint][]programs.Room) - roomNames := []string{ - "Room A", "Room B", "Classroom 1", "Classroom 2", - "Computer Lab", "Library", "Auditorium", "Conference Room", - } - for _, facility := range facilities { - var rooms []programs.Room - for _, name := range roomNames { - room := programs.Room{ - FacilityID: facility.ID, - Name: name, - } - if err := db.Create(&room).Error; err != nil { - log.Printf("Failed to create room %s for facility %d: %v", name, facility.ID, err) - continue - } - rooms = append(rooms, room) - } - facilityRooms[facility.ID] = rooms - log.Printf("Created %d rooms for facility %s", len(rooms), facility.Name) - } - toReturn := make([]programs.ProgramClassCohort, 0) - seededPrograms := make([]programs.Program, 0, 7) - for range 7 { - seededPrograms = append(seededPrograms, programs.Program{ - Name: getRandomProgram(programMap), - FundingType: fundingTypes[rand.Intn(len(fundingTypes))], - }) - } - for i := range seededPrograms { - seededPrograms[i].Description = programClassDescriptions[seededPrograms[i].Name] - seededPrograms[i].IsActive = true - if err := db.Create(&seededPrograms[i]).Error; err != nil { - log.Printf("Failed to create program: %v", err) - } - for idx := range facilities { - capacities := []int64{15, 25, 30, 35, 40, 45} - endDates := []time.Time{time.Now().Add(20 * 24 * time.Hour), time.Now().Add(25 * 24 * time.Hour), time.Now().Add(30 * 24 * time.Hour), time.Now().Add(35 * 24 * time.Hour)} - facilityProgram := programs.FacilitiesPrograms{ - ProgramID: seededPrograms[i].ID, - FacilityID: facilities[idx].ID, - ProgramOwner: models.StringPtr(faker.Name()), - } - if err := db.Create(&facilityProgram).Error; err != nil { - log.Printf("Failed to create facility program: %v", err) - } - for range rand.Intn(4) { - programType := programs.ProgramType{ - ProgramType: programMap[seededPrograms[i].Name], - ProgramID: seededPrograms[i].ID, - } - if err := db.Create(&programType).Error; err != nil { - log.Printf("Failed to create program type: %v", err) - } - creditType := programs.ProgramCreditType{ - CreditType: creditTypes[rand.Intn(len(creditTypes))], - ProgramID: seededPrograms[i].ID, - } - if err := db.Create(&creditType).Error; err != nil { //we can do multiple credit types if we want, add this during new development if needed - log.Printf("Failed to create program credit type: %v", err) - } - // One CLASS, then 1-3 COHORTS under it. The multi-cohort shape is the - // point: a 1:1 seed never exercises a single class-level rollup. - programClass := programs.ProgramClass{ - Name: seededPrograms[i].Name, - Description: models.NilIfBlank(programClassDescriptions[seededPrograms[i].Name]), - CreditHours: &creditHourOptions[rand.Intn(len(creditHourOptions))], - FacilityID: facilities[idx].ID, - ProgramID: seededPrograms[i].ID, - } - if err := db.Create(&programClass).Error; err != nil { - log.Printf("Failed to create program class: %v", err) - continue - } - - // Sometimes give the class its own credit type, so the - // empty-means-inherit branch and the override branch both get hit. - if rand.Intn(3) == 0 { - if err := db.Create(&programs.ProgramClassCreditType{ - ClassID: programClass.ID, - CreditType: creditTypes[rand.Intn(len(creditTypes))], - }).Error; err != nil { - log.Printf("Failed to create class credit type: %v", err) - } - } - - cohortsForClass := rand.Intn(3) + 1 - for c := range cohortsForClass { - cohort := programs.ProgramClassCohort{ - ClassID: programClass.ID, - // Read-only alias, so this is never written -- it just carries the - // name in memory for the completion records built further down. - ClassName: programClass.Name, - Capacity: capacities[rand.Intn(len(capacities))], - Description: programClassDescriptions[seededPrograms[i].Name], - Status: programs.Scheduled, //this will change during new class development - StartDt: time.Now().Add(time.Duration(14+7*c) * 24 * time.Hour), - EndDt: &endDates[rand.Intn(len(endDates))], - FacilityID: facilities[idx].ID, - ProgramID: seededPrograms[i].ID, - } - if err := db.Create(&cohort).Error; err != nil { - log.Printf("Failed to create program class cohort: %v", err) - continue - } - log.Println("Creating program class cohort ", cohort.ID) - toReturn = append(toReturn, cohort) - seedCohortEvents(db, cohort, facilityRooms[facilities[idx].ID]) - } - } - } - } - return toReturn, nil -} - -var creditHourOptions = []int64{5, 10, 15, 20} - -// resolveCreditType mirrors the production rule in newClassCompletion: the class's own -// credit types override the program's, and an EMPTY class-level set means inherit. The -// seeder gives only some classes their own credit type, so both branches get exercised. -func resolveCreditType(db *gorm.DB, classID, programID uint) programs.CreditType { - var creditType programs.CreditType - if err := db.Model(&programs.ProgramClassCreditType{}). - Where("class_id = ?", classID). - Limit(1). - Pluck("credit_type", &creditType).Error; err == nil && creditType != "" { - return creditType - } - if err := db.Model(&programs.ProgramCreditType{}). - Where("program_id = ?", programID). - Limit(1). - Pluck("credit_type", &creditType).Error; err == nil && creditType != "" { - return creditType - } - return programs.Completion -} - -func seedCohortEvents(db *gorm.DB, cohort programs.ProgramClassCohort, rooms []programs.Room) { - if len(rooms) == 0 || cohort.EndDt == nil { - return - } - days := []rrule.Weekday{rrule.MO, rrule.TU, rrule.WE, rrule.TH, rrule.FR, rrule.SA, rrule.SU} - // Sample WITHOUT replacement: a duplicate weekday in Byweekday makes the rule - // generate the same occurrence twice, which then seeds duplicate attendance. - rand.Shuffle(len(days), func(i, j int) { days[i], days[j] = days[j], days[i] }) - randDays := days[:rand.Intn(3)+1] - rule, err := rrule.NewRRule(rrule.ROption{ - Freq: rrule.WEEKLY, - Dtstart: cohort.StartDt, - Until: *cohort.EndDt, - Byweekday: randDays, - }) - if err != nil { - log.Printf("Failed to create rrule: %v", err) - return - } - roomID := rooms[rand.Intn(len(rooms))].ID - event := programs.ProgramClassEvent{ - CohortID: cohort.ID, - RecurrenceRule: rule.String(), - RoomID: &roomID, - Duration: "1h0m0s", - } - if err := db.Create(&event).Error; err != nil { - log.Printf("Failed to create event: %v", err) - } -} - func generateFakeUsers(facilities []models.Facility) []models.User { users := make([]models.User, 0, 30) for range 30 { diff --git a/backend/src/database/users.go b/backend/src/database/users.go index 7ca2205b0..d316237b2 100644 --- a/backend/src/database/users.go +++ b/backend/src/database/users.go @@ -10,8 +10,6 @@ import ( "time" "github.com/UnlockedLabs/unlocked-core/backend/src/models" - programsapi "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/registry" log "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -137,17 +135,11 @@ func (db *DB) GetUsersByIDs(ctx context.Context, userIDs []uint, facID uint, rol return users, nil } -func (db *DB) TransferResident(ctx *models.QueryContext, userID int, currFacilityID int, transFacilityID int) (*gorm.DB, error) { +func (db *DB) TransferResident(ctx *models.QueryContext, userID int, transFacilityID int) (*gorm.DB, error) { trans := db.Begin() if trans.Error != nil { return nil, NewDBError(trans.Error, "unable to start DB transaction") } - if err := registry.ProgramsInTx(trans, func(p programsapi.Programs) error { - return p.EndEnrollmentsForTransfer(trans.Statement.Context, uint(userID), uint(currFacilityID)) - }); err != nil { - trans.Rollback() - return nil, newUpdateDBError(err, "program_class_enrollments") - } if err := trans.Model(&models.User{}). Where("id = ?", userID). Update("facility_id", transFacilityID).Error; err != nil { @@ -872,11 +864,6 @@ func (db *DB) DeactivateUser(ctx context.Context, userID uint, adminID *uint) er if err := tx.Model(&models.User{}).Where("id = ?", userID).Update("deactivated_at", now).Error; err != nil { return newUpdateDBError(err, "users") } - if err := registry.ProgramsInTx(tx, func(p programsapi.Programs) error { - return p.WithdrawEnrollments(ctx, userID) - }); err != nil { - return newUpdateDBError(err, "program_class_enrollments") - } history := models.NewUserAccountHistory(userID, models.UserDeactivated, adminID, nil, nil) if err := tx.Create(&history).Error; err != nil { return newCreateDBError(err, "user_account_history") diff --git a/backend/src/handlers/facilities_handler.go b/backend/src/handlers/facilities_handler.go index 7179d6613..abc8277a0 100644 --- a/backend/src/handlers/facilities_handler.go +++ b/backend/src/handlers/facilities_handler.go @@ -8,9 +8,9 @@ import ( "github.com/UnlockedLabs/unlocked-core/backend/src/database" "github.com/UnlockedLabs/unlocked-core/backend/src/models" - programsapi "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api" ocapi "github.com/UnlockedLabs/unlocked-contracts/opencontent" + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" ) func (srv *Server) registerFacilitiesRoutes() []routeDef { diff --git a/backend/src/handlers/internal_features.go b/backend/src/handlers/internal_features.go index 52b1387e5..f6a0475e1 100644 --- a/backend/src/handlers/internal_features.go +++ b/backend/src/handlers/internal_features.go @@ -25,7 +25,7 @@ func (srv *Server) registerInternalRoutes() { srv.Mux.Handle("GET /internal/v1/features", srv.handleError(srv.handleInternalFeatures)) srv.Mux.Handle("GET /internal/v1/claims", srv.handleError(srv.handleInternalClaims)) srv.Mux.Handle("PUT /internal/v1/features/{name}", srv.handleError(srv.handleInternalRegisterFeature)) - srv.Mux.Handle("/internal/v1/core/", coreapi.NewHandler(coreUsers{srv}, registry.LiveProviders(srv.moduleDeps()), registry.ProviderPlatformsToken())) + srv.Mux.Handle("/internal/v1/core/", coreapi.NewHandler(coreUsers{srv}, liveProviders{srv}, registry.ProviderPlatformsToken())) } type internalFeaturesResponse struct { diff --git a/backend/src/handlers/modules.go b/backend/src/handlers/modules.go index 81db37159..91db30dee 100644 --- a/backend/src/handlers/modules.go +++ b/backend/src/handlers/modules.go @@ -158,6 +158,19 @@ func (u coreUsers) Delete(ctx context.Context, id uint) error { return nil } +// liveProviders passes the Provider Platforms service's questions about +// Canvas and Essential Education (coreapi.LiveProviders) on to Programs, which +// reads those providers. +type liveProviders struct{ srv *Server } + +func (l liveProviders) Users(ctx context.Context, providerID uint) ([]coreapi.ProviderUser, bool, error) { + return l.srv.programs.LiveUsers(ctx, providerID) +} + +func (l liveProviders) MappingsChanged(providerID, userID uint) { + l.srv.programs.MappingsChanged(providerID, userID) +} + // httpClient sends module requests with srv.Client. It reads srv.Client on // each call, so a client set after startup is the one used. type httpClient struct{ srv *Server } diff --git a/backend/src/handlers/modules_test.go b/backend/src/handlers/modules_test.go index e4796c754..122b44b6c 100644 --- a/backend/src/handlers/modules_test.go +++ b/backend/src/handlers/modules_test.go @@ -14,6 +14,7 @@ import ( "github.com/UnlockedLabs/unlocked-contracts/coreapi" "github.com/UnlockedLabs/unlocked-contracts/identity" + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" "github.com/UnlockedLabs/unlocked-contracts/users" ory "github.com/ory/kratos-client-go" "github.com/sirupsen/logrus" @@ -504,7 +505,41 @@ func TestInternalCoreAPI(t *testing.T) { _, err := coreapi.NewUsersClient(core.URL, "wrong", nil).Get(context.Background(), admin.ID) assert.Error(t, err) }) - t.Run("clearing the Canvas cache without a cache is harmless", func(t *testing.T) { - assert.NotPanics(t, func() { coreapi.NewLiveProvidersClient(core.URL, "s3cret", nil).MappingsChanged(1, 1) }) + t.Run("live provider questions go to Programs", func(t *testing.T) { + progs := &livePrograms{users: []coreapi.ProviderUser{{Username: "ee.jdoe", ExternalUserID: "8001"}}} + srv.programs = progs + live := coreapi.NewLiveProvidersClient(core.URL, "s3cret", nil) + got, ok, err := live.Users(context.Background(), 11) + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, progs.users, got) + live.MappingsChanged(10, admin.ID) + progs.mu.Lock() + defer progs.mu.Unlock() + assert.Equal(t, []uint{11}, progs.asked) + assert.Equal(t, [][2]uint{{10, admin.ID}}, progs.changed) }) } + +// livePrograms is Programs' answers about live providers. It records what it +// was asked; any other question panics. +type livePrograms struct { + programsapi.Programs + users []coreapi.ProviderUser + mu sync.Mutex + asked []uint + changed [][2]uint +} + +func (p *livePrograms) LiveUsers(_ context.Context, providerID uint) ([]coreapi.ProviderUser, bool, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.asked = append(p.asked, providerID) + return p.users, true, nil +} + +func (p *livePrograms) MappingsChanged(providerID, userID uint) { + p.mu.Lock() + defer p.mu.Unlock() + p.changed = append(p.changed, [2]uint{providerID, userID}) +} diff --git a/backend/src/handlers/server.go b/backend/src/handlers/server.go index ce2873344..0f87fccf7 100644 --- a/backend/src/handlers/server.go +++ b/backend/src/handlers/server.go @@ -14,11 +14,11 @@ import ( database "github.com/UnlockedLabs/unlocked-core/backend/src/database" "github.com/UnlockedLabs/unlocked-core/backend/src/models" "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - programsapi "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api" "github.com/UnlockedLabs/unlocked-core/backend/src/modules/registry" "github.com/UnlockedLabs/unlocked-core/backend/src/tasks" ocapi "github.com/UnlockedLabs/unlocked-contracts/opencontent" + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" "github.com/nats-io/nats.go" ory "github.com/ory/kratos-client-go" "github.com/prometheus/client_golang/prometheus" @@ -192,7 +192,7 @@ func newServer(ctx context.Context) *Server { moduleDBs: moduleDBs, openContent: ocapi.NewClient(openContentURL(), os.Getenv("OPEN_CONTENT_TOKEN"), nil), providerPlatforms: registry.ProviderPlatforms(), - programs: registry.Programs(moduleDBs.For), + programs: programsapi.NewClient(programsURL(), os.Getenv("PROGRAMS_TOKEN"), nil), } if err := server.setupNatsKvBuckets(); err != nil { log.Fatalf("Failed to setup JetStream KV store: %v", err) @@ -227,7 +227,7 @@ func newTestingServer() *Server { moduleDBs: moduleDBs, openContent: ocapi.NewClient(openContentURL(), os.Getenv("OPEN_CONTENT_TOKEN"), nil), providerPlatforms: registry.ProviderPlatforms(), - programs: registry.Programs(moduleDBs.For), + programs: programsapi.NewClient(programsURL(), os.Getenv("PROGRAMS_TOKEN"), nil), } } @@ -240,6 +240,15 @@ func openContentURL() string { return "http://open-content:8080" } +// programsURL is where core asks the Programs service its questions: +// PROGRAMS_URL, else the service's name in the cluster. +func programsURL() string { + if url := os.Getenv("PROGRAMS_URL"); url != "" { + return url + } + return "http://programs:8080" +} + // ConnectNATS connects to NATS with NATS_URL, NATS_USER and NATS_PASSWORD. The // scheduler process uses it too. func ConnectNATS() (*nats.Conn, error) { diff --git a/backend/src/handlers/user_handler.go b/backend/src/handlers/user_handler.go index aa893b798..951aa56ea 100644 --- a/backend/src/handlers/user_handler.go +++ b/backend/src/handlers/user_handler.go @@ -18,7 +18,8 @@ import ( "github.com/UnlockedLabs/unlocked-core/backend/src/database" "github.com/UnlockedLabs/unlocked-core/backend/src/jasper" "github.com/UnlockedLabs/unlocked-core/backend/src/models" - programsapi "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api" + + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" ) // maxDocIDLen matches the users.doc_id column (VARCHAR(32), migration 00042). @@ -613,7 +614,12 @@ func (srv *Server) handleResidentTransfer(w http.ResponseWriter, r *http.Request if user.DeactivatedAt != nil { return newBadRequestServiceError(errors.New("cannot transfer deactivated user"), "User is deactivated") } - tx, err := srv.Db.TransferResident(&args, transRequest.UserID, transRequest.CurrFacilityID, transRequest.TransFacilityID) + // Programs goes first, and ending the enrollments again changes nothing, + // so a transfer that fails after this is finished by trying again. + if err := srv.programs.EndEnrollmentsForTransfer(r.Context(), uint(transRequest.UserID), uint(transRequest.CurrFacilityID)); err != nil { + return newInternalServerServiceError(err, "error updating program_class_enrollments") + } + tx, err := srv.Db.TransferResident(&args, transRequest.UserID, transRequest.TransFacilityID) if err != nil { return newDatabaseServiceError(err) } @@ -839,14 +845,26 @@ func (srv *Server) handleDeactivateUser(w http.ResponseWriter, r *http.Request, } claims := r.Context().Value(ClaimsKey).(*Claims) - err = srv.Db.DeactivateUser(r.Context(), uint(id), &claims.UserID) - if err != nil { - return newDatabaseServiceError(err) + if err := srv.deactivateUser(r.Context(), uint(id), claims.UserID); err != nil { + return err } return writeJsonResponse(w, http.StatusOK, "User deactivated successfully") } +// deactivateUser withdraws the user's program enrollments, then deactivates +// the account. Programs goes first, and withdrawing again changes nothing, so +// a deactivation that fails after that is finished by trying again. +func (srv *Server) deactivateUser(ctx context.Context, userID, adminID uint) error { + if err := srv.programs.WithdrawEnrollments(ctx, userID); err != nil { + return newInternalServerServiceError(err, "error updating program_class_enrollments") + } + if err := srv.Db.DeactivateUser(ctx, userID, &adminID); err != nil { + return newDatabaseServiceError(err) + } + return nil +} + func (srv *Server) handleGenerateUsageReportPDF(w http.ResponseWriter, r *http.Request, log sLog) error { userID, err := strconv.Atoi(r.PathValue("id")) if err != nil { @@ -1004,7 +1022,7 @@ func (srv *Server) handleBulkDeactivateUsers(w http.ResponseWriter, r *http.Requ var successCount int var failures []failedEntry for _, user := range users { - if err := srv.Db.DeactivateUser(r.Context(), user.ID, &claims.UserID); err != nil { + if err := srv.deactivateUser(r.Context(), user.ID, claims.UserID); err != nil { log.add("user_id", user.ID) log.error("bulk deactivate: error deactivating user") failures = append(failures, failedEntry{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error deactivating user"}) diff --git a/backend/src/jasper/jasper_service.go b/backend/src/jasper/jasper_service.go index 62a890b42..bb73887b4 100644 --- a/backend/src/jasper/jasper_service.go +++ b/backend/src/jasper/jasper_service.go @@ -15,9 +15,9 @@ import ( "github.com/UnlockedLabs/unlocked-core/backend/src/database" "github.com/UnlockedLabs/unlocked-core/backend/src/models" "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - programsapi "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api" ocapi "github.com/UnlockedLabs/unlocked-contracts/opencontent" + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" "github.com/evertonvps/go-jasper" "github.com/google/uuid" "github.com/sirupsen/logrus" diff --git a/backend/src/modules/learningrecord/README.md b/backend/src/modules/learningrecord/README.md index 8f982f7b9..818daaafc 100644 --- a/backend/src/modules/learningrecord/README.md +++ b/backend/src/modules/learningrecord/README.md @@ -12,9 +12,8 @@ EN-195. - **It owns no data but its own table** (`learning_record_entries`, in the `learning_record` schema; see Database). An entry's `program_name` is free text, not a foreign key into Programs, so it - has nothing to ask Programs' `api/` package today. The depguard rule for this - module already allow-lists `modules/programs/api` for whenever that changes. - Provider Platforms is its own service (EN-206), so it isn't a module here. + has nothing to ask Programs today. Provider Platforms (EN-206) and Programs + (EN-207) are their own services, so they aren't modules here. - **It reads and writes some of core's tables:** it reads `facilities` for the achievement-location picker (the `facility` type in `models.go`), and adds a `user_account_history` row when a resident deletes an entry, in the same diff --git a/backend/src/modules/programs/README.md b/backend/src/modules/programs/README.md deleted file mode 100644 index d86c4f521..000000000 --- a/backend/src/modules/programs/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# Programs module - -Programs, their classes and cohorts, enrollments, class events and -attendance, rooms, instructors, the Programs reports, and the Canvas and -Essential Education classes shown alongside them. It moved here from core in -EN-194. - -## Boundaries - -- **It imports nothing from core.** It talks to core only through - `modules/module` (the seam) and `unlocked-contracts`. depguard in `backend/.golangci.yml` enforces this. -- **Core asks it questions through `api/`** (interfaces and plain types only), - and gets the implementation from `registry.Programs`: the users list's - `include=only_enrolled|only_unenrolled` filters, resident transfer and - deactivation (on core's transaction, with `registry.ProgramsInTx`), the - usage report, the dashboard's Programs numbers, and the facility list's, - delete check's and account history's Programs data. Core never imports - this package's insides. -- **It reaches core through `Deps`:** - - `Deps.HTTP` for its Canvas and Essential Education calls; - - `Deps.Features` to hide Canvas classes at facilities with Provider - Platforms turned off; - - `Deps.Reports` to render report PDFs with core's Jasper templates. -- **It answers the Provider Platforms service's `coreapi.LiveProviders`**, - through `registry.LiveProviders`, which core serves under - `/internal/v1/core/`: an Essential Education provider's users, and clearing - the cached Canvas programs when a mapping changes. That goes away when - Provider Platforms fetches and caches those providers' data itself. -- **It reads and writes some of core's tables:** it reads `users` and - `facilities` (the `User` and `Facility` types in `models.go`), and adds - rows to core's audit tables in the same transaction as its own changes: - `user_account_history` when attendance is recorded, `change_log_entries` - when a class or its schedule changes. Deleting a program or class deletes - its `change_log_entries` rows too. -- **It asks the Provider Platforms service** about providers and the users - linked to them, with the unlocked-contracts `providerplatforms` client from - `registry.ProviderPlatforms`, so Programs' role needs no grants on provider - tables. -- **Its routes keep core's access checks.** 35 of the 65 routes have a check - of their own (`Route.Allow`), which `handlers.go` copies from core's - resolvers. -- **Some code outside the module imports its insides:** - - `backend/seeder`; - - the integration tests, which build their data with its types and call its - queries through `env.Programs` (the exported `Store`). depguard exempts - `backend/tests/integration` for this. -- **Class activation** runs in the scheduler from `RegisterJobs` (`jobs.go`), - every morning at 5 and once at start outside dev, with its queries in - `program_classes_store.go`. - -## Database - -- **Its tables live in the `programs` schema** (EN-200). Its first migration - moved all 14 there from `public`, with their sequences, and its rollback - moves them back. -- **Its connection runs as the `programs` role**, which can read and write - only this schema, read core's `users` and `facilities`, read and add to - (never change) core's `user_account_history`, and read, add to and delete - from (never change) core's `change_log_entries`. - Postgres refuses anything else, so core asks for Programs' numbers through - `api/` instead of reading its tables. -- **The role can't log in.** `Schema()` in `module.go` names the schema, and - core opens this module its own pool as the app's usual database user - (`database.OpenModuleDBs`). Each connection switches with - `SET ROLE programs` and `search_path = programs, public`, so queries stay - unqualified and no site needs a new password. -- **Core's transactions run Programs' part as the role.** Resident transfer - and deactivation change Programs' enrollments inside core's transaction, - through `registry.ProgramsInTx`, which wraps the call in `module.RunAs` - (`SET LOCAL ROLE programs` and the search_path, then back to core's). -- **The history trigger** on `programs`, `program_classes` and - `program_class_cohorts` is `programs.log_program_classes_updates()`, which - writes `programs.program_classes_history`. -- **Later migrations must create tables as `programs.`.** Module - migrations run on core's connection, so an unqualified table lands in - `public`, where the role can't use it. Tables created here get the role's - grants automatically. -- **The enum types** (`class_status`, `credit_type`, `funding_type`, - `program_type`) stay in `public`. -- **On SQLite** (the tests) there are no schemas or roles, so Programs uses - core's connection. - -## Files - -| File | What it holds | -|---|---| -| `module.go` | Registers the routes and class activation, opens the Canvas cache buckets, lists the test tables, answers `Deps.LiveProviders` | -| `jobs.go` | Class activation | -| `handlers.go` | The routes, with their access levels, checks and feature flags | -| `programs_handler.go`, `program_class_tier_handler.go`, `classes_handler.go`, `class_events.go`, `class_enrollments.go`, `events_attendance_handler.go`, `reports_handler.go`, `facilities_handler.go`, `users_handler.go` | The handlers | -| `classes_service.go`, `users_service.go` | Class details, missing attendance and facility health; a resident's weekly attendance trend | -| `canvas_programs.go`, `canvas_classes_cache.go`, `canvas_events_cache.go`, `essential_ed_programs.go`, `live_provider.go` | The Canvas and Essential Education classes, read live and cached in NATS | -| `store.go` and `*_store.go` | Every database query, for the routes and the api | -| `api.go` | The answers to core's questions (`api.Programs`) | -| `program.go`, `program_classes.go`, `class_event.go`, `calendar.go`, `reports.go`, `change_log_entry.go`, `models.go` | Programs' tables and types, and the rows of core's tables it uses | -| `http.go` | Responses, errors, paging and request helpers, matching core's JSON | -| `utils.go` | Recurrence-rule helpers | -| `migrations/` | This module's own migrations: `00001` moves its tables into the `programs` schema and creates its role | - -## Tests - -- `backend/tests/integration/programs_routes_test.go` pins every route's - status, JSON, access level and feature flag, the core routes that ask - `api/`, and the Canvas and Essential Education paths through a TLS fake, - against the saved responses in `testdata/programs/`. After an intended - change, regenerate them from `backend/` with: - - ```bash - go test ./tests/integration -run 'TestPrograms' -update - ``` - - The changed JSON then shows in the PR for review. -- `backend/tests/integration/programs_core_reads_test.go` pins the core - routes that ask `api/` for Programs' data (the facility list and delete - check, resident transfer, the account history), against - `testdata/programs_core_reads/`. -- The other integration tests cover classes, events, attendance, conflicts, - delete guards, reports and the Programs insights. -- The unit tests here cover the Canvas and Essential Education readers, the - live providers, the calendar, enrollment checks, graduation and the PDF - export. -- The tests run on SQLite without NATS, Canvas, Essential Education or - Jasper, so they can't run the Postgres-only queries (pinned as 500s in the - route tests), the NATS caches, the PDF rendering, or the schema and role. - Check those with `make dev`. diff --git a/backend/src/modules/programs/api.go b/backend/src/modules/programs/api.go deleted file mode 100644 index a0f5e12bc..000000000 --- a/backend/src/modules/programs/api.go +++ /dev/null @@ -1,204 +0,0 @@ -package programs - -import ( - "context" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api" - - "gorm.io/gorm" -) - -// programsAPI answers core's questions (api.Programs). The queries moved from -// core's database package unchanged (EN-194). Its errors are the database's -// own, for core to wrap in its database errors as it did before the move. -type programsAPI struct{ store *Store } - -// NewAPI is Programs' answers to core, reading db. -func NewAPI(db *gorm.DB) api.Programs { - return programsAPI{&Store{db}} -} - -func (a programsAPI) EnrolledUserIDs(ctx context.Context, classID uint) ([]uint, error) { - ids, err := a.store.GetEnrolledResidentIDsForClass(ctx, int(classID)) - return ids, dbCause(err) -} - -func (a programsAPI) EligibleUserIDs(ctx context.Context, classID uint) ([]uint, error) { - ids, err := a.store.GetEligibleResidentIDsForClass(ctx, int(classID)) - return ids, dbCause(err) -} - -func (a programsAPI) TransferConflicts(ctx context.Context, userID, toFacilityID uint) ([]api.ResidentTransferProgramConflicts, error) { - return a.store.GetTransferProgramConflicts(ctx, userID, int(toFacilityID)) -} - -func (a programsAPI) EndEnrollmentsForTransfer(ctx context.Context, userID, fromFacilityID uint) error { - return a.store.WithContext(ctx).Exec(`UPDATE program_class_enrollments AS pce SET enrollment_status = ? - FROM program_class_cohorts pc - WHERE pce.cohort_id = pc.id - AND pce.user_id = ? - AND pc.facility_id = ? - AND pce.enrollment_status = 'Enrolled'`, - "Incomplete: Transferred", userID, fromFacilityID).Error -} - -// WithdrawEnrollments updates through the model, so the enrollment's -// BeforeUpdate hook sets enrollment_ended_at. -func (a programsAPI) WithdrawEnrollments(ctx context.Context, userID uint) error { - return a.store.WithContext(ctx).Model(&ProgramClassEnrollment{}). - Where("user_id = ? AND enrollment_status = ?", userID, Enrolled). - Updates(map[string]any{ - "enrollment_status": EnrollmentIncompleteWithdrawn, - "change_reason": "Account deactivated", - }).Error -} - -func (a programsAPI) ResidentPrograms(ctx context.Context, userID uint) ([]api.ResidentProgramClassInfo, error) { - // The usage report has always listed every enrollment, latest class start - // first. - args := QueryContext{Ctx: ctx, All: true, OrderBy: "start_dt", Order: "DESC"} - rows, err := a.store.GetUserProgramInfo(&args, int(userID)) - if err != nil { - return nil, dbCause(err) - } - out := make([]api.ResidentProgramClassInfo, len(rows)) - for i := range rows { - rows[i].CalculateAttendancePercentage() - out[i] = toAPIResidentProgram(rows[i]) - } - return out, nil -} - -func toAPIResidentProgram(p ResidentProgramClassInfo) api.ResidentProgramClassInfo { - return api.ResidentProgramClassInfo{ - ProgramName: p.ProgramName, - ClassName: p.ClassName, - Status: string(p.Status), - EnrollmentStatus: string(p.EnrollmentStatus), - StartDate: p.StartDate, - EndDate: p.EndDate, - AttendancePercentage: p.AttendancePercentage, - CohortID: p.CohortID, - ProgramID: p.ProgramID, - EnrollmentID: p.EnrollmentID, - PresentAttendance: p.PresentAttendance, - AbsentAttendance: p.AbsentAttendance, - CreditTypes: p.CreditTypes, - UpdatedAt: p.UpdatedAt, - ChangeReason: p.ChangeReason, - Schedule: p.Schedule, - IsCanvas: p.IsCanvas, - Source: p.Source, - } -} - -// FacilityPrograms counts with the conditions core's facility list and -// delete check used before they asked Programs (EN-200). -func (a programsAPI) FacilityPrograms(ctx context.Context, facilityIDs []uint) (map[uint]api.FacilityPrograms, error) { - counts := make(map[uint]api.FacilityPrograms) - if len(facilityIDs) == 0 { - return counts, nil - } - db := a.store.WithContext(ctx) - queries := []struct { - query *gorm.DB - facility string - set func(*api.FacilityPrograms, int64) - }{ - {db.Table("facilities_programs fp"). - Select("fp.facility_id, COUNT(DISTINCT p.id) AS count"). - Joins("JOIN programs p ON p.id = fp.program_id AND p.deleted_at IS NULL"). - Where("fp.deleted_at IS NULL AND p.is_active = true AND p.archived_at IS NULL"), - "fp.facility_id", func(c *api.FacilityPrograms, n int64) { c.ActivePrograms = n }}, - {db.Table("program_class_cohorts"). - Select("facility_id, COUNT(*) AS count"). - Where("deleted_at IS NULL AND status = ? AND archived_at IS NULL", Active), - "facility_id", func(c *api.FacilityPrograms, n int64) { c.ActiveClasses = n }}, - {db.Table("program_classes"). - Select("facility_id, COUNT(*) AS count"). - Where("deleted_at IS NULL"), - "facility_id", func(c *api.FacilityPrograms, n int64) { c.Classes = n }}, - {db.Table("facilities_programs fp"). - Select("fp.facility_id, COUNT(*) AS count"). - Where("fp.deleted_at IS NULL"), - "fp.facility_id", func(c *api.FacilityPrograms, n int64) { c.Programs = n }}, - {db.Table("rooms"). - Select("facility_id, COUNT(*) AS count"). - Where("deleted_at IS NULL"), - "facility_id", func(c *api.FacilityPrograms, n int64) { c.Rooms = n }}, - } - for _, q := range queries { - var rows []struct { - FacilityID uint - Count int64 - } - if err := q.query.Where(q.facility+" IN ?", facilityIDs). - Group(q.facility). - Scan(&rows).Error; err != nil { - return nil, err - } - for _, row := range rows { - c := counts[row.FacilityID] - q.set(&c, row.Count) - counts[row.FacilityID] = c - } - } - return counts, nil -} - -func (a programsAPI) ClassHistoryCreatedAt(ctx context.Context, ids []uint) (map[uint]time.Time, error) { - times := make(map[uint]time.Time) - if len(ids) == 0 { - return times, nil - } - var rows []struct { - ID uint - CreatedAt time.Time - } - if err := a.store.WithContext(ctx).Table("program_classes_history"). - Select("id, created_at"). - Where("id IN ? AND created_at IS NOT NULL", ids). - Scan(&rows).Error; err != nil { - return nil, err - } - for _, row := range rows { - times[row.ID] = row.CreatedAt - } - return times, nil -} - -func (a programsAPI) EngagementOverview(ctx context.Context, start, end *time.Time, facilityID *uint) (api.ProgramEngagementOverview, error) { - overview, err := a.store.GetProgramEngagementOverview(&QueryContext{Ctx: ctx}, start, end, facilityID) - return overview, dbCause(err) -} - -func (a programsAPI) SecondProgramEnrollment(ctx context.Context, facilityID *uint) ([]api.SecondProgramEnrollmentRow, error) { - rows, err := a.store.GetSecondProgramEnrollmentRates(&QueryContext{Ctx: ctx}, facilityID) - return rows, dbCause(err) -} - -func (a programsAPI) CompletionMatrix(ctx context.Context, facilityID *uint) ([]api.ProgramCompletionMatrixCell, error) { - cells, err := a.store.GetProgramCompletionMatrix(&QueryContext{Ctx: ctx}, facilityID) - return cells, dbCause(err) -} - -func (a programsAPI) LoadDistribution(ctx context.Context, facilityID *uint) (api.ProgramLoadDistribution, error) { - dist, err := a.store.GetProgramLoadDistribution(&QueryContext{Ctx: ctx}, facilityID) - return dist, dbCause(err) -} - -func (a programsAPI) EnrollmentByProgramType(ctx context.Context, facilityID *uint) ([]api.ProgramTypeEnrollment, error) { - rows, err := a.store.GetEnrollmentByProgramType(&QueryContext{Ctx: ctx}, facilityID) - return rows, dbCause(err) -} - -func (a programsAPI) ClassMetrics(ctx context.Context, facilityID *uint) (api.ClassDashboardMetrics, error) { - metrics, err := a.store.GetClassDashboardMetrics(&QueryContext{Ctx: ctx}, facilityID) - return metrics, dbCause(err) -} - -func (a programsAPI) FacilityHealth(ctx context.Context, facilityID *uint, days int, tz string) ([]api.FacilityHealthSummary, error) { - summaries, err := NewClassesService(a.store).GetFacilityHealthSummaries(&QueryContext{Ctx: ctx, Timezone: tz}, facilityID, days) - return summaries, dbCause(err) -} diff --git a/backend/src/modules/programs/api/api.go b/backend/src/modules/programs/api/api.go deleted file mode 100644 index b68a65231..000000000 --- a/backend/src/modules/programs/api/api.go +++ /dev/null @@ -1,195 +0,0 @@ -// Package api is the only part of programs that core and other feature modules -// may import: the questions Programs answers for them. -// -// The JSON names match what core already sends, so a response built from -// these types doesn't change. -package api - -import ( - "context" - "time" -) - -// Programs answers core's questions about classes, enrollments and the -// Programs numbers on the dashboard. -type Programs interface { - // EnrolledUserIDs is the users enrolled in a class right now. - EnrolledUserIDs(ctx context.Context, classID uint) ([]uint, error) - // EligibleUserIDs is the residents who could join a class: active - // residents at the class's facility, which offers its program, who have no - // enrollment in the class. - EligibleUserIDs(ctx context.Context, classID uint) ([]uint, error) - - // TransferConflicts is the classes a resident is enrolled in at their - // facility whose program toFacilityID doesn't offer. - TransferConflicts(ctx context.Context, userID, toFacilityID uint) ([]ResidentTransferProgramConflicts, error) - // EndEnrollmentsForTransfer marks the resident's current enrollments at - // fromFacilityID as ended by a transfer. Call it through - // registry.ProgramsInTx to make it part of the transfer's transaction. - EndEnrollmentsForTransfer(ctx context.Context, userID, fromFacilityID uint) error - // WithdrawEnrollments marks the user's current enrollments as withdrawn, - // because their account was deactivated. Call it through - // registry.ProgramsInTx to make it part of the deactivation's transaction. - WithdrawEnrollments(ctx context.Context, userID uint) error - - // ResidentPrograms is every enrollment a resident has had, with its - // attendance percentage, latest class start first, for the usage report. - ResidentPrograms(ctx context.Context, userID uint) ([]ResidentProgramClassInfo, error) - - // FacilityPrograms is the Programs data each of facilityIDs holds, for - // core's facility list and facility delete check. A facility that holds - // none is left out. - FacilityPrograms(ctx context.Context, facilityIDs []uint) (map[uint]FacilityPrograms, error) - // ClassHistoryCreatedAt is when each of the program_classes_history rows - // ids was written, for a resident's account history. A row with no time is - // left out. - ClassHistoryCreatedAt(ctx context.Context, ids []uint) (map[uint]time.Time, error) - - // The Programs numbers on the dashboard, for facilityID (every facility - // when nil). EngagementOverview counts the activity between start and end, - // or all of it when they are nil. - EngagementOverview(ctx context.Context, start, end *time.Time, facilityID *uint) (ProgramEngagementOverview, error) - SecondProgramEnrollment(ctx context.Context, facilityID *uint) ([]SecondProgramEnrollmentRow, error) - CompletionMatrix(ctx context.Context, facilityID *uint) ([]ProgramCompletionMatrixCell, error) - LoadDistribution(ctx context.Context, facilityID *uint) (ProgramLoadDistribution, error) - EnrollmentByProgramType(ctx context.Context, facilityID *uint) ([]ProgramTypeEnrollment, error) - ClassMetrics(ctx context.Context, facilityID *uint) (ClassDashboardMetrics, error) - // FacilityHealth counts the missing attendance over the last days (3 when - // days isn't positive), with class times read in the time zone tz. - FacilityHealth(ctx context.Context, facilityID *uint, days int, tz string) ([]FacilityHealthSummary, error) -} - -// ResidentTransferProgramConflicts is a class a resident would leave behind by -// transferring. -type ResidentTransferProgramConflicts struct { - ProgramName string `json:"program_name"` - ClassName string `json:"class_name"` -} - -// FacilityPrograms counts the Programs rows recorded at a facility. -type FacilityPrograms struct { - // ActivePrograms and ActiveClasses are the facility list's numbers. - ActivePrograms int64 - ActiveClasses int64 - // Classes, Programs (the programs the facility offers) and Rooms block - // deleting the facility. - Classes int64 - Programs int64 - Rooms int64 -} - -// ResidentProgramClassInfo is one of a resident's enrollments. -type ResidentProgramClassInfo struct { - ProgramName string `json:"program_name"` - ClassName string `json:"class_name"` - Status string `json:"status"` - EnrollmentStatus string `json:"enrollment_status"` - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` - AttendancePercentage string `json:"attendance_percentage"` - CohortID uint `json:"cohort_id"` - ProgramID uint `json:"program_id"` - EnrollmentID uint `json:"enrollment_id"` - PresentAttendance int `json:"present_attendance"` - AbsentAttendance int `json:"absent_attendance"` - CreditTypes string `json:"credit_types"` - UpdatedAt string `json:"updated_at"` - ChangeReason string `json:"change_reason"` - Schedule string `json:"schedule"` - IsCanvas bool `json:"is_canvas"` - Source string `json:"source,omitempty"` -} - -// ProgramEngagementOverview is how many residents take part in programs. -type ProgramEngagementOverview struct { - ActiveResidents int64 `json:"active_residents"` - PreviouslyEngagedResidents int64 `json:"previously_engaged_residents"` - NeverEngagedResidents int64 `json:"never_engaged_residents"` - TotalResidents int64 `json:"total_residents"` - TopPrograms []ProgramCompletionRank `json:"top_programs"` -} - -// ProgramCompletionRank is one program's completions, for the top programs. -type ProgramCompletionRank struct { - ProgramName string `json:"program_name"` - Enrolled int64 `json:"enrolled"` - Completed int64 `json:"completed"` - CompletionRate float64 `json:"completion_rate"` -} - -// SecondProgramEnrollmentRow is the rate at which residents who completed a -// program enrolled in another, for one facility and program type. -type SecondProgramEnrollmentRow struct { - FacilityName string `json:"facility_name"` - ProgramType string `json:"program_type"` - CompletedFirst int64 `json:"completed_first"` - EnrolledSecond int64 `json:"enrolled_second"` - Rate float64 `json:"rate"` -} - -// ProgramCompletionMatrixCell is one program's completions at one facility. -type ProgramCompletionMatrixCell struct { - ProgramName string `json:"program_name"` - FacilityID uint `json:"facility_id"` - FacilityName string `json:"facility_name"` - Enrolled int64 `json:"enrolled"` - Completed int64 `json:"completed"` - CompletionRate float64 `json:"completion_rate"` - Insufficient bool `json:"insufficient"` - DeltaFromFacilityAverage float64 `json:"delta_from_facility_average"` -} - -// ProgramLoadDistribution is how many programs active residents are enrolled -// in, statewide and by facility. -type ProgramLoadDistribution struct { - Statewide []ProgramLoadBucket `json:"statewide"` - ByFacility []ProgramLoadFacilityRow `json:"by_facility"` -} - -// ProgramLoadBucket is how many residents are enrolled in Bucket programs. -type ProgramLoadBucket struct { - Bucket string `json:"bucket"` // "0", "1", "2", "3", "4+" - Count int64 `json:"count"` -} - -// ProgramLoadFacilityRow is one facility's residents by how many programs -// they are enrolled in. -type ProgramLoadFacilityRow struct { - FacilityID uint `json:"facility_id"` - FacilityName string `json:"facility_name"` - Zero int64 `json:"zero"` - One int64 `json:"one"` - Two int64 `json:"two"` - Three int64 `json:"three"` - FourPlus int64 `json:"four_plus"` - Total int64 `json:"total"` -} - -// ProgramTypeEnrollment is the enrollments and completions for one program -// type. -type ProgramTypeEnrollment struct { - ProgramType string `json:"program_type"` - Enrolled int64 `json:"enrolled"` - Completed int64 `json:"completed"` - Rate float64 `json:"rate"` -} - -// ClassDashboardMetrics is the class numbers on the dashboard. -type ClassDashboardMetrics struct { - ActiveClasses int64 `json:"active_classes"` - ScheduledClasses int64 `json:"scheduled_classes"` - TotalEnrollments int64 `json:"total_enrollments"` - TotalSeats int64 `json:"total_seats"` - AttendanceConcerns int64 `json:"attendance_concerns"` -} - -// FacilityHealthSummary is one facility's programs, classes and attendance. -type FacilityHealthSummary struct { - FacilityID uint `json:"facility_id"` - FacilityName string `json:"facility_name"` - Programs int64 `json:"programs"` - ActiveClasses int64 `json:"active_classes"` - Enrollment int64 `json:"enrollment"` - MissingAttendance int64 `json:"missing_attendance"` - AttendanceConcerns int64 `json:"attendance_concerns"` -} diff --git a/backend/src/modules/programs/calendar.go b/backend/src/modules/programs/calendar.go deleted file mode 100644 index 1ac32c963..000000000 --- a/backend/src/modules/programs/calendar.go +++ /dev/null @@ -1,40 +0,0 @@ -package programs - -import "time" - -const ( - Onwards string = "onwards" - Self string = "self" - All string = "all" -) - -type EventInstance struct { - EventID uint `json:"event_id"` - CohortID uint `json:"cohort_id"` - ProgramName string `json:"program_name"` - StartTime time.Time `json:"start_time"` - Duration time.Duration `json:"duration"` - Room string `json:"location"` - IsCancelled bool `json:"is_cancelled"` -} - -type Day struct { - DayIdx int `json:"day_index"` - Date time.Time `json:"date"` - Events []EventInstance `json:"events"` -} - -type OverrideForm struct { - RoomID *uint `json:"room_id"` - Date string `json:"date"` - StartTime string `json:"start_time"` - Duration string `json:"duration"` - IsCancelled bool `json:"is_cancelled"` - OverrideType string `json:"override_type"` -} - -const ( - OverrideAll string = "all" - OverrideForwards string = "forwards" - OverrideSelf string = "self" -) diff --git a/backend/src/modules/programs/calendar_store_test.go b/backend/src/modules/programs/calendar_store_test.go deleted file mode 100644 index 34a64a016..000000000 --- a/backend/src/modules/programs/calendar_store_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package programs - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/teambition/rrule-go" -) - -func TestGenerateEventInstances_SimpleRecurrence_NoOverrides(t *testing.T) { - rule, err := rrule.NewRRule(rrule.ROption{ - Freq: rrule.WEEKLY, - Count: 5, - Dtstart: time.Date(2024, 9, 2, 10, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatal("failed to create rrule", err) - } - event := ProgramClassEvent{ - CohortID: 100, - Duration: time.Hour.String(), - RecurrenceRule: rule.String(), - Overrides: []ProgramClassEventOverride{}, - } - - startDate := time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC) - endDate := time.Date(2024, 9, 30, 23, 59, 59, 0, time.UTC) - - instances := generateEventInstances(event, startDate, endDate) - - expectedDates := []time.Time{ - time.Date(2024, 9, 2, 10, 0, 0, 0, time.UTC), - time.Date(2024, 9, 9, 10, 0, 0, 0, time.UTC), - time.Date(2024, 9, 16, 10, 0, 0, 0, time.UTC), - time.Date(2024, 9, 23, 10, 0, 0, 0, time.UTC), - time.Date(2024, 9, 30, 10, 0, 0, 0, time.UTC), - } - - assert.Len(t, instances, len(expectedDates), "Should generate 5 instances") - - for i, instance := range instances { - assert.Equal(t, event.ID, instance.EventID, "EventID should match") - assert.Equal(t, event.CohortID, instance.CohortID, "CohortID should match") - assert.Equal(t, expectedDates[i], instance.StartTime, "StartTime should match") - assert.Equal(t, event.Duration, instance.Duration.String(), "Duration should match") - assert.False(t, instance.IsCancelled, "IsCancelled should be false") - } -} - -func TestGenerateEventInstances_CancellationOverride(t *testing.T) { - event := ProgramClassEvent{ - CohortID: 200, - Duration: time.Hour.String(), - RecurrenceRule: "DTSTART:20240901T090000Z\n" + - "RRULE:FREQ=DAILY;COUNT=7", - Overrides: []ProgramClassEventOverride{ - { - EventID: 2, - OverrideRrule: "DTSTART:20240904T090000Z\nRRULE:FREQ=DAILY;COUNT=1", - IsCancelled: true, - }, - }, - } - - startDate := time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC) - endDate := time.Date(2024, 9, 7, 23, 59, 59, 0, time.UTC) - - instances := generateEventInstances(event, startDate, endDate) - - expectedDates := []time.Time{ - time.Date(2024, 9, 1, 9, 0, 0, 0, time.UTC), - time.Date(2024, 9, 2, 9, 0, 0, 0, time.UTC), - time.Date(2024, 9, 3, 9, 0, 0, 0, time.UTC), - // Skipping September 4 due to cancellation - time.Date(2024, 9, 5, 9, 0, 0, 0, time.UTC), - time.Date(2024, 9, 6, 9, 0, 0, 0, time.UTC), - time.Date(2024, 9, 7, 9, 0, 0, 0, time.UTC), - } - - assert.Len(t, instances, len(expectedDates), "Should generate 6 instances after cancellation") - - for i, instance := range instances { - assert.Equal(t, event.ID, instance.EventID, "EventID should match") - assert.Equal(t, expectedDates[i], instance.StartTime, "StartTime should match") - assert.False(t, instance.IsCancelled, "IsCancelled should be false") - } -} - -func TestGenerateEventInstances_ModificationOverride(t *testing.T) { - newDuration := 90 * time.Minute - rule, err := rrule.NewRRule(rrule.ROption{ - Dtstart: time.Date(2024, 9, 1, 10, 0, 0, 0, time.UTC), - Freq: rrule.WEEKLY, - Count: 4, - Byweekday: []rrule.Weekday{rrule.SU}, - }) - if err != nil { - t.Fatalf("failed to create rrule: %v", err) - } - overRule, err := rrule.NewRRule(rrule.ROption{ - Dtstart: time.Date(2024, 9, 15, 10, 0, 0, 0, time.UTC), - Freq: rrule.WEEKLY, - Byweekday: []rrule.Weekday{rrule.SU}, - }) - if err != nil { - t.Fatalf("failed to create rrule: %v", err) - } - event := ProgramClassEvent{ - CohortID: 300, - Duration: time.Hour.String(), - RecurrenceRule: rule.String(), - Overrides: []ProgramClassEventOverride{ - { - EventID: 3, - OverrideRrule: overRule.String(), - IsCancelled: false, - Duration: newDuration.String(), - }, - }, - } - - startDate := time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC) - endDate := time.Date(2024, 9, 28, 23, 59, 59, 0, time.UTC) - - instances := generateEventInstances(event, startDate, endDate) - - expectedInstances := []EventInstance{ - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 1, 10, 0, 0, 0, time.UTC), - Duration: time.Duration(1 * time.Hour), - IsCancelled: false, - }, - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 8, 10, 0, 0, 0, time.UTC), - Duration: time.Duration(1 * time.Hour), - IsCancelled: false, - }, - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 15, 10, 0, 0, 0, time.UTC), - Duration: newDuration, - IsCancelled: false, - }, - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 22, 10, 0, 0, 0, time.UTC), - Duration: newDuration, - IsCancelled: false, - }, - } - - assert.Len(t, instances, len(expectedInstances), "Should generate 4 instances with modified duration from September 15") - - for i, instance := range instances { - expected := expectedInstances[i] - assert.Equal(t, expected.EventID, instance.EventID, "EventID should match") - assert.Equal(t, expected.StartTime, instance.StartTime, "StartTime should match") - assert.Equal(t, expected.Duration, instance.Duration, "Duration should match") - assert.False(t, instance.IsCancelled, "IsCancelled should be false") - } -} - -func TestGenerateEventInstances_MultipleOverrides(t *testing.T) { - newDuration := 2 * time.Hour - rule, err := rrule.NewRRule(rrule.ROption{ - Dtstart: time.Date(2024, 9, 1, 8, 0, 0, 0, time.UTC), - Freq: rrule.DAILY, - Count: 7, - }) - if err != nil { - t.Fatal("failed to create rrule", err) - } - - rule1, err := rrule.NewRRule(rrule.ROption{ - Dtstart: time.Date(2024, 9, 3, 8, 0, 0, 0, time.UTC), - Freq: rrule.DAILY, - Count: 1, - }) - if err != nil { - t.Fatal("failed to create rrule", err) - } - override1 := ProgramClassEventOverride{ - EventID: 4, - OverrideRrule: rule1.String(), - IsCancelled: true, - } - - rule2, err := rrule.NewRRule(rrule.ROption{ - Dtstart: time.Date(2024, 9, 5, 8, 0, 0, 0, time.UTC), - Freq: rrule.DAILY, - Until: time.Date(2024, 9, 7, 8, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatal("failed to create rrule", err) - } - override2 := ProgramClassEventOverride{ - EventID: 4, - IsCancelled: false, - Duration: newDuration.String(), - OverrideRrule: rule2.String(), - } - event := ProgramClassEvent{ - CohortID: 400, - Duration: time.Hour.String(), - RecurrenceRule: rule.String(), - Overrides: []ProgramClassEventOverride{override1, override2}, - } - startDate := time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC) - endDate := time.Date(2024, 9, 7, 23, 59, 59, 0, time.UTC) - instances := generateEventInstances(event, startDate, endDate) - - expectedInstances := []EventInstance{ - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 1, 8, 0, 0, 0, time.UTC), - Duration: time.Duration(1 * time.Hour), - IsCancelled: false, - }, - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 2, 8, 0, 0, 0, time.UTC), - Duration: time.Duration(1 * time.Hour), - IsCancelled: false, - }, - // Skipping September 3 due to cancellation - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 4, 8, 0, 0, 0, time.UTC), - Duration: time.Duration(1 * time.Hour), - IsCancelled: false, - }, - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 5, 8, 0, 0, 0, time.UTC), - Duration: newDuration, - IsCancelled: false, - }, - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 6, 8, 0, 0, 0, time.UTC), - Duration: newDuration, - IsCancelled: false, - }, - { - EventID: event.ID, - StartTime: time.Date(2024, 9, 7, 8, 0, 0, 0, time.UTC), - Duration: newDuration, // Modified duration - IsCancelled: false, - }, - } - - assert.Len(t, instances, len(expectedInstances), "Should generate 6 instances with cancellations and modifications") - - for i, instance := range instances { - expected := expectedInstances[i] - assert.Equal(t, expected.EventID, instance.EventID, "EventID should match") - assert.Equal(t, expected.StartTime, instance.StartTime, "StartTime should match") - assert.Equal(t, expected.Duration, instance.Duration, "Duration should match") - assert.False(t, instance.IsCancelled, "IsCancelled should be false") - } -} - -func TestGenerateEventInstances_NoOccurrencesInRange(t *testing.T) { - rule, err := rrule.NewRRule(rrule.ROption{ - Dtstart: time.Date(2024, 10, 1, 8, 0, 0, 0, time.UTC), - Freq: rrule.DAILY, - Count: 5, - }) - if err != nil { - t.Fatal("failed to create rrule", err) - } - event := ProgramClassEvent{ - CohortID: 500, - Duration: time.Hour.String(), - RecurrenceRule: rule.String(), - Overrides: []ProgramClassEventOverride{}, - } - - startDate := time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC) - endDate := time.Date(2024, 9, 30, 23, 59, 59, 0, time.UTC) - - instances := generateEventInstances(event, startDate, endDate) - assert.Len(t, instances, 0, "Should generate no instances as event occurs outside the date range") -} - -func TestGenerateEventInstances_InvalidRRULE(t *testing.T) { - event := ProgramClassEvent{ - CohortID: 600, - Duration: time.Hour.String(), - RecurrenceRule: "INVALID_RRULE", - Overrides: []ProgramClassEventOverride{}, - } - - startDate := time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC) - endDate := time.Date(2024, 9, 30, 23, 59, 59, 0, time.UTC) - instances := generateEventInstances(event, startDate, endDate) - assert.Len(t, instances, 0, "Instances should be nil when an error occurs") -} - -// dedupeClassEventInstances merges instances expanded from multiple event rows (the -// state left behind by "Apply to all future sessions"). It must keep distinct -// sessions, collapse true duplicates, and let the newest row win on a collision. -func TestDedupeClassEventInstances_PrefersNewestRowOnCollision(t *testing.T) { - // EventID 1 is the older capped row, EventID 2 is the newer row. Both produce the - // same date and start time — the newest row should win. - instances := []ClassEventInstance{ - {EventID: 1, Date: "2026-01-05", ClassTime: "10:00-11:00"}, - {EventID: 2, Date: "2026-01-05", ClassTime: "10:00-11:00"}, - {EventID: 1, Date: "2026-01-12", ClassTime: "10:00-11:00"}, - } - - result := dedupeClassEventInstances(instances) - - assert.Len(t, result, 2, "duplicate date+time should collapse to one instance") - // Sorted by date descending. - assert.Equal(t, "2026-01-12", result[0].Date) - assert.Equal(t, "2026-01-05", result[1].Date) - assert.Equal(t, uint(2), result[1].EventID, "newest row should win on collision") -} - -func TestDedupeClassEventInstances_KeepsDistinctTimesSameDate(t *testing.T) { - instances := []ClassEventInstance{ - {EventID: 1, Date: "2026-01-05", ClassTime: "09:00-10:00"}, - {EventID: 1, Date: "2026-01-05", ClassTime: "13:00-14:00"}, - } - - result := dedupeClassEventInstances(instances) - - assert.Len(t, result, 2, "different start times on the same date are distinct sessions") -} - -func TestDedupeClassEventInstances_MergesAcrossRowsWithoutGap(t *testing.T) { - // Simulates the bug: an older capped row's past sessions plus a newer row's - // future sessions must all survive the merge. - pastFromOldRow := []ClassEventInstance{ - {EventID: 1, Date: "2026-01-05", ClassTime: "10:00-11:00"}, - {EventID: 1, Date: "2026-01-12", ClassTime: "10:00-11:00"}, - } - futureFromNewRow := []ClassEventInstance{ - {EventID: 2, Date: "2026-01-19", ClassTime: "14:00-15:00"}, - {EventID: 2, Date: "2026-01-26", ClassTime: "14:00-15:00"}, - } - - result := dedupeClassEventInstances(append(pastFromOldRow, futureFromNewRow...)) - - dates := make([]string, len(result)) - for i, inst := range result { - dates[i] = inst.Date - } - assert.Equal(t, []string{"2026-01-26", "2026-01-19", "2026-01-12", "2026-01-05"}, dates, - "all sessions from both rows should be present, sorted by date descending") -} - -func TestDedupeEventDates_PrefersNewestRowOnCollision(t *testing.T) { - dates := []EventDates{ - {EventID: 1, Date: "2026-01-05", ClassTime: "10:00-11:00"}, - {EventID: 2, Date: "2026-01-05", ClassTime: "10:00-11:00"}, - {EventID: 1, Date: "2026-01-12", ClassTime: "10:00-11:00"}, - } - - result := dedupeEventDates(dates) - - assert.Len(t, result, 2, "duplicate date+classTime should collapse to one entry") - foundCollisionDate := false - for _, d := range result { - if d.Date == "2026-01-05" { - foundCollisionDate = true - assert.Equal(t, uint(2), d.EventID, "newest row should win on collision") - } - } - assert.True(t, foundCollisionDate, "expected deduped result to include 2026-01-05") -} - -func TestDedupeEventDates_KeepsDistinctTimesSameDate(t *testing.T) { - dates := []EventDates{ - {EventID: 1, Date: "2026-01-05", ClassTime: "09:00-10:00"}, - {EventID: 1, Date: "2026-01-05", ClassTime: "13:00-14:00"}, - } - - result := dedupeEventDates(dates) - - assert.Len(t, result, 2, "different class times on the same date are distinct entries") -} diff --git a/backend/src/modules/programs/canvas_classes_cache.go b/backend/src/modules/programs/canvas_classes_cache.go deleted file mode 100644 index 32be6a0a8..000000000 --- a/backend/src/modules/programs/canvas_classes_cache.go +++ /dev/null @@ -1,196 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "fmt" - "time" - - log "github.com/sirupsen/logrus" -) - -// Reading a provider's classes is slow -- a course listing plus an enrollment -// count per course, multiplied by every facility when the index is statewide. -// Doing that on the request path made the classes page wait on the provider -// before showing the classes we already have in our own tables. Instead the -// result is cached in NATS KV and refreshed in the background, exactly as -// getCanvasProviderPrograms does for the programs page. - -// canvasClassCacheTTL is how long a cached class listing is served before it is -// refreshed. It matches the bucket TTL. -const canvasClassCacheTTL = 5 * time.Minute - -type CachedCanvasClasses struct { - Classes []ProgramClassCohort - LastUpdated time.Time - Loading bool -} - -// canvasClassCacheKey scopes the cache to the facility being viewed. The -// statewide index builds facility-scoped IDs for every facility, so it cannot -// share an entry with a single-facility view. -func canvasClassCacheKey(facilityID *uint) string { - if facilityID == nil { - return "canvas_classes_all" - } - return fmt.Sprintf("canvas_classes_%d", *facilityID) -} - -// getCanvasProviderClasses returns the cached provider classes for this view. -// A fresh entry is returned as-is. On a miss or a stale entry it returns -// loading=true and refreshes in the background, so the caller can answer with -// the classes it already has rather than waiting. -func (srv *server) getCanvasProviderClasses(facilityID *uint) (classes []ProgramClassCohort, loading bool) { - kv := srv.buckets[CanvasClasses] - if kv == nil { - log.Warn("canvas_classes NATS bucket is nil, skipping cache") - return nil, false - } - cacheKey := canvasClassCacheKey(facilityID) - - if entry, err := kv.Get(cacheKey); err == nil { - var cached CachedCanvasClasses - if json.Unmarshal(entry.Value(), &cached) == nil { - if cached.Loading { - // A refresh is already running; show what it had last, if anything. - return cached.Classes, true - } - if cached.LastUpdated.Add(canvasClassCacheTTL).After(time.Now()) { - return cached.Classes, false - } - // Stale: serve the old classes while the refresh runs, so the list does - // not empty out every five minutes. - srv.warmCanvasClassCache(facilityID, cacheKey, cached.Classes) - return cached.Classes, true - } - } - - srv.warmCanvasClassCache(facilityID, cacheKey, nil) - return nil, true -} - -// warmCanvasClassCache refreshes a cached class listing in the background. -// previous is carried into the loading marker so a stale-but-usable list keeps -// being served while the refresh runs. -func (srv *server) warmCanvasClassCache(facilityID *uint, cacheKey string, previous []ProgramClassCohort) { - if _, loaded := srv.canvasInflight.LoadOrStore(cacheKey, struct{}{}); loaded { - return - } - kv := srv.buckets[CanvasClasses] - if kv == nil { - srv.canvasInflight.Delete(cacheKey) - return - } - srv.putCanvasClasses(cacheKey, CachedCanvasClasses{Classes: previous, LastUpdated: time.Now(), Loading: true}) - - var scoped *uint - if facilityID != nil { - id := *facilityID - scoped = &id - } - go func() { - defer srv.canvasInflight.Delete(cacheKey) - var ( - classes []ProgramClassCohort - failed []uint - err error - ) - if scoped == nil { - classes, failed, err = srv.fetchCanvasClassesAllProvidersAllFacilities() - } else { - classes, failed, err = srv.fetchCanvasClassesAllProviders(scoped) - } - if err != nil { - log.WithError(err).Warn("warmCanvasClassCache: failed to fetch provider classes") - // Clear the loading marker, and date the entry so the next request - // refreshes it. Writing time.Now() here would serve this failed result - // as fresh for a full TTL instead. - srv.restoreCanvasClasses(cacheKey, previous) - return - } - if len(failed) > 0 { - // A partial listing must not be stored as fresh: the providers that - // failed contributed nothing, so their classes would vanish from the - // page for a whole TTL. Keep serving what they had and retry sooner. - log.Warnf("warmCanvasClassCache: %d provider(s) failed, carrying over their cached classes", len(failed)) - classes = carryOverFailedProviders(classes, previous, failed, - func(c ProgramClassCohort) uint { return c.ProgramID }) - if len(classes) == 0 { - srv.restoreCanvasClasses(cacheKey, previous) - return - } - srv.putCanvasClasses(cacheKey, CachedCanvasClasses{ - Classes: classes, - LastUpdated: retryableCacheTimestamp(canvasClassCacheTTL), - }) - return - } - srv.putCanvasClasses(cacheKey, CachedCanvasClasses{Classes: classes, LastUpdated: time.Now(), Loading: false}) - }() -} - -// restoreCanvasClasses puts a failed refresh back into a retryable state: the -// stale listing it was replacing, dated so the next request refreshes it, or no -// entry at all when there is nothing worth serving. -func (srv *server) restoreCanvasClasses(cacheKey string, previous []ProgramClassCohort) { - if previous == nil { - if kv := srv.buckets[CanvasClasses]; kv != nil { - if err := kv.Delete(cacheKey); err != nil { - log.WithError(err).Warnf("restoreCanvasClasses: failed to clear loading marker at %s", cacheKey) - } - } - return - } - srv.putCanvasClasses(cacheKey, CachedCanvasClasses{ - Classes: previous, - LastUpdated: retryableCacheTimestamp(canvasClassCacheTTL), - }) -} - -func (srv *server) putCanvasClasses(cacheKey string, cached CachedCanvasClasses) { - kv := srv.buckets[CanvasClasses] - if kv == nil { - return - } - data, err := json.Marshal(cached) - if err != nil { - log.WithError(err).Warn("warmCanvasClassCache: failed to encode class cache entry") - return - } - if _, err := kv.Put(cacheKey, data); err != nil { - log.WithError(err).Warn("warmCanvasClassCache: failed to write class cache entry") - } -} - -// loadingClassPlaceholders returns one row per live provider so the classes page -// can show that a provider is still syncing. They carry Loading so the frontend -// can keep them out of the filtered list and render them as skeleton rows. -func (srv *server) loadingClassPlaceholders(facilityID *uint) []ProgramClassCohort { - providers, err := srv.activeProviders(context.Background()) - if err != nil { - log.WithError(err).Warn("loadingClassPlaceholders: failed to list provider platforms") - return nil - } - var scopedFacility uint - if facilityID != nil { - scopedFacility = *facilityID - } - var placeholders []ProgramClassCohort - for _, provider := range providers { - if !isLiveProgramProvider(&provider) { - continue - } - cls := ProgramClassCohort{ - ProgramID: CanvasProgramIDOffset + provider.ID, - FacilityID: scopedFacility, - ClassName: provider.Name, - Status: Active, - IsCanvas: true, - Source: providerSourceLabel(&provider), - Loading: true, - } - cls.ID = CanvasProgramIDOffset + provider.ID - placeholders = append(placeholders, cls) - } - return placeholders -} diff --git a/backend/src/modules/programs/canvas_classes_cache_test.go b/backend/src/modules/programs/canvas_classes_cache_test.go deleted file mode 100644 index 521c920cd..000000000 --- a/backend/src/modules/programs/canvas_classes_cache_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package programs - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// The statewide index builds facility-scoped class IDs for every facility, so it -// must not read the entry a single-facility view wrote. -func TestCanvasClassCacheKey(t *testing.T) { - facility := func(id uint) *uint { return &id } - - tests := []struct { - name string - facilityID *uint - want string - }{ - {"statewide view", nil, "canvas_classes_all"}, - {"a single facility", facility(1), "canvas_classes_1"}, - {"a different facility", facility(2), "canvas_classes_2"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, canvasClassCacheKey(tt.facilityID)) - }) - } -} - -// With no NATS bucket the page must still work: no cached classes, and no -// loading state to poll for, so the handler simply returns our own classes. -func TestGetCanvasProviderClassesWithoutBucket(t *testing.T) { - srv := &server{} - classes, loading := srv.getCanvasProviderClasses(nil) - assert.Nil(t, classes) - assert.False(t, loading, "without a cache there is nothing to wait for") -} - -// A provider that fails a refresh contributes no rows, so without carrying its -// previous rows over its classes would disappear from the page and that absence -// would be cached as a complete listing. -func TestCarryOverFailedProviders(t *testing.T) { - const ( - healthy = uint(1) - broken = uint(2) - ) - class := func(providerID uint, name string) ProgramClassCohort { - return ProgramClassCohort{ - ProgramID: CanvasProgramIDOffset + providerID, - ClassName: name, - } - } - programID := func(c ProgramClassCohort) uint { return c.ProgramID } - - tests := []struct { - name string - fresh []ProgramClassCohort - previous []ProgramClassCohort - failed []uint - want []ProgramClassCohort - }{ - { - name: "no failures leaves the fresh listing alone", - fresh: []ProgramClassCohort{class(healthy, "canvas")}, - previous: []ProgramClassCohort{class(healthy, "stale canvas")}, - failed: nil, - want: []ProgramClassCohort{class(healthy, "canvas")}, - }, - { - name: "one provider down keeps its previous classes", - fresh: []ProgramClassCohort{class(healthy, "canvas")}, - previous: []ProgramClassCohort{class(healthy, "stale canvas"), class(broken, "college prep")}, - failed: []uint{broken}, - want: []ProgramClassCohort{class(healthy, "canvas"), class(broken, "college prep")}, - }, - { - name: "nothing to carry over on a cold cache", - fresh: []ProgramClassCohort{class(healthy, "canvas")}, - previous: nil, - failed: []uint{broken}, - want: []ProgramClassCohort{class(healthy, "canvas")}, - }, - { - name: "every provider down with no previous entry yields nothing", - fresh: nil, - previous: nil, - failed: []uint{healthy, broken}, - want: nil, - }, - { - name: "every provider down carries the whole previous listing", - fresh: nil, - previous: []ProgramClassCohort{class(healthy, "canvas"), class(broken, "college prep")}, - failed: []uint{healthy, broken}, - want: []ProgramClassCohort{class(healthy, "canvas"), class(broken, "college prep")}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := carryOverFailedProviders(tt.fresh, tt.previous, tt.failed, programID) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/backend/src/modules/programs/canvas_events_cache.go b/backend/src/modules/programs/canvas_events_cache.go deleted file mode 100644 index bd983ba1e..000000000 --- a/backend/src/modules/programs/canvas_events_cache.go +++ /dev/null @@ -1,175 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "fmt" - "time" - - log "github.com/sirupsen/logrus" -) - -// The admin calendar used to fetch a live provider's events on the request -// path, so the whole calendar waited on Canvas and Essential Ed before showing -// the events we already have in our own tables. Instead the provider events are -// cached in NATS KV and refreshed in the background, exactly as -// canvas_classes_cache.go does for the classes page. - -// canvasEventCacheTTL is how long a cached event listing is served before it is -// refreshed. It matches the bucket TTL. -const canvasEventCacheTTL = 5 * time.Minute - -type CachedCanvasEvents struct { - Events []FacilityProgramClassEvent - LastUpdated time.Time - Loading bool -} - -// canvasEventCacheKey scopes the cache to the facility and the visible date -// range. The calendar pages week to week, so each range is its own entry; day -// granularity is enough, and keeps a few seconds of drift in the requested -// range from fragmenting the cache. -func canvasEventCacheKey(facilityID uint, dtRng *DateRange) string { - return fmt.Sprintf("canvas_events_%d_%s_%s", facilityID, - dtRng.Start.Format("2006-01-02"), dtRng.End.Format("2006-01-02")) -} - -// getCanvasProviderEvents returns the cached provider events for this facility -// and date range. A fresh entry is returned as-is. On a miss or a stale entry it -// returns loading=true and refreshes in the background, so the caller can answer -// with the events it already has rather than waiting. -func (srv *server) getCanvasProviderEvents(dtRng *DateRange, facilityID uint) (events []FacilityProgramClassEvent, loading bool) { - kv := srv.buckets[CanvasEvents] - if kv == nil { - log.Warn("canvas_events NATS bucket is nil, skipping cache") - return nil, false - } - cacheKey := canvasEventCacheKey(facilityID, dtRng) - - if entry, err := kv.Get(cacheKey); err == nil { - var cached CachedCanvasEvents - if json.Unmarshal(entry.Value(), &cached) == nil { - if cached.Loading { - // A refresh is already running; show what it had last, if anything. - return cached.Events, true - } - if cached.LastUpdated.Add(canvasEventCacheTTL).After(time.Now()) { - return cached.Events, false - } - // Stale: serve the old events while the refresh runs, so the calendar - // does not empty out every five minutes. - srv.warmCanvasEventCache(dtRng, facilityID, cacheKey, cached.Events) - return cached.Events, true - } - } - - srv.warmCanvasEventCache(dtRng, facilityID, cacheKey, nil) - return nil, true -} - -// warmCanvasEventCache refreshes a cached event listing in the background. -// previous is carried into the loading marker so a stale-but-usable list keeps -// being served while the refresh runs. -func (srv *server) warmCanvasEventCache(dtRng *DateRange, facilityID uint, cacheKey string, previous []FacilityProgramClassEvent) { - if _, loaded := srv.canvasInflight.LoadOrStore(cacheKey, struct{}{}); loaded { - return - } - kv := srv.buckets[CanvasEvents] - if kv == nil { - srv.canvasInflight.Delete(cacheKey) - return - } - srv.putCanvasEvents(cacheKey, CachedCanvasEvents{Events: previous, LastUpdated: time.Now(), Loading: true}) - - scoped := *dtRng - go func() { - defer srv.canvasInflight.Delete(cacheKey) - events, failed, err := srv.appendCanvasEventsForFacility(&scoped, facilityID) - if err != nil { - log.WithError(err).Warn("warmCanvasEventCache: failed to fetch provider events") - // Clear the loading marker, and date the entry so the next request - // refreshes it. Writing time.Now() here would serve this failed result - // as fresh for a full TTL instead. - srv.restoreCanvasEvents(cacheKey, previous) - return - } - if len(failed) > 0 { - // A partial listing must not be stored as fresh: the providers that - // failed contributed nothing, so their events would vanish from the - // calendar for a whole TTL. Keep serving what they had and retry sooner. - log.Warnf("warmCanvasEventCache: %d provider(s) failed, carrying over their cached events", len(failed)) - events = carryOverFailedProviders(events, previous, failed, - func(e FacilityProgramClassEvent) uint { return e.ProgramID }) - if len(events) == 0 { - srv.restoreCanvasEvents(cacheKey, previous) - return - } - srv.putCanvasEvents(cacheKey, CachedCanvasEvents{ - Events: events, - LastUpdated: retryableCacheTimestamp(canvasEventCacheTTL), - }) - return - } - srv.putCanvasEvents(cacheKey, CachedCanvasEvents{Events: events, LastUpdated: time.Now(), Loading: false}) - }() -} - -// restoreCanvasEvents puts a failed refresh back into a retryable state: the -// stale listing it was replacing, dated so the next request refreshes it, or no -// entry at all when there is nothing worth serving. -func (srv *server) restoreCanvasEvents(cacheKey string, previous []FacilityProgramClassEvent) { - if previous == nil { - if kv := srv.buckets[CanvasEvents]; kv != nil { - if err := kv.Delete(cacheKey); err != nil { - log.WithError(err).Warnf("restoreCanvasEvents: failed to clear loading marker at %s", cacheKey) - } - } - return - } - srv.putCanvasEvents(cacheKey, CachedCanvasEvents{ - Events: previous, - LastUpdated: retryableCacheTimestamp(canvasEventCacheTTL), - }) -} - -func (srv *server) putCanvasEvents(cacheKey string, cached CachedCanvasEvents) { - kv := srv.buckets[CanvasEvents] - if kv == nil { - return - } - data, err := json.Marshal(cached) - if err != nil { - log.WithError(err).Warn("warmCanvasEventCache: failed to encode event cache entry") - return - } - if _, err := kv.Put(cacheKey, data); err != nil { - log.WithError(err).Warn("warmCanvasEventCache: failed to write event cache entry") - } -} - -// loadingEventPlaceholders returns one marker per live provider so the calendar -// can show which provider is still syncing. They carry Loading and no times, so -// the frontend keeps them out of the grid and renders them as an indicator. -func (srv *server) loadingEventPlaceholders() []FacilityProgramClassEvent { - providers, err := srv.activeProviders(context.Background()) - if err != nil { - log.WithError(err).Warn("loadingEventPlaceholders: failed to list provider platforms") - return nil - } - var placeholders []FacilityProgramClassEvent - for _, provider := range providers { - if !isLiveProgramProvider(&provider) { - continue - } - ev := FacilityProgramClassEvent{ - ProgramID: CanvasProgramIDOffset + provider.ID, - ProgramName: provider.Name, - IsCanvasEvent: true, - Source: providerSourceLabel(&provider), - Loading: true, - } - ev.ID = CanvasProgramIDOffset + provider.ID - placeholders = append(placeholders, ev) - } - return placeholders -} diff --git a/backend/src/modules/programs/canvas_programs.go b/backend/src/modules/programs/canvas_programs.go deleted file mode 100644 index a4acb6622..000000000 --- a/backend/src/modules/programs/canvas_programs.go +++ /dev/null @@ -1,2277 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "regexp" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/features" - "github.com/UnlockedLabs/unlocked-contracts/providerplatforms" - "github.com/UnlockedLabs/unlocked-contracts/users" - log "github.com/sirupsen/logrus" -) - -type CachedCanvasProgram struct { - Program ProgramsOverviewTable - LastUpdated time.Time - CompletionRate *float64 - Loading bool -} - -// liveCourse holds one provider course normalized into the shape the live -// program path uses, regardless of which provider it came from. -type liveCourse struct { - encodedID uint - rawID uint - name string - description string - startDt time.Time - endDt *time.Time - status ClassStatus - enrolleeIDs []string - weeklySchedule map[time.Weekday][]meetingTime -} - -type meetingTime struct { - startMin int - endMin int -} - -// isCanvasProvider returns true when the provider is a Canvas (OSS or Cloud) instance. -func isCanvasProvider(p *providerplatforms.Provider) bool { - return p.Type == providerplatforms.CanvasOSS || p.Type == providerplatforms.CanvasCloud -} - -// encodeCanvasClassID packs (providerID, rawCourseID) into the synthetic class ID. -// decodeCanvasClassID is the reverse. -func encodeCanvasClassID(providerID, rawCourseID uint) uint { - return CanvasClassIDOffset + providerID*1_000_000 + rawCourseID -} - -// parseCanvasCourse extracts metadata from a raw Canvas API course map. -// Returns (entry, true) on success, or the zero value and false when the map -// has no valid numeric id field. -func parseCanvasCourse(course map[string]interface{}, providerID uint, now time.Time) (liveCourse, bool) { - idFloat, ok := course["id"].(float64) - if !ok { - return liveCourse{}, false - } - rawID := uint(idFloat) - if !courseIDFitsEncoding(rawID) { - log.Warnf("canvas course %d exceeds the synthetic class ID range, skipping", rawID) - return liveCourse{}, false - } - name, _ := course["name"].(string) - description, _ := course["course_code"].(string) - var startDt time.Time - if s, ok := course["start_at"].(string); ok && s != "" { - startDt, _ = time.Parse("2006-01-02T15:04:05Z", s) - } - var endDt *time.Time - status := Active - if s, ok := course["end_at"].(string); ok && s != "" { - if t, err := time.Parse("2006-01-02T15:04:05Z", s); err == nil { - endDt = &t - if !t.After(now) { - status = Completed - } - } - } - return liveCourse{ - encodedID: encodeCanvasClassID(providerID, rawID), - rawID: rawID, - name: name, - description: description, - startDt: startDt, - endDt: endDt, - status: status, - }, true -} - -// buildCourseTimezoneMap extracts the "time_zone" field from each Canvas API -// course map and returns a mapping of raw course ID → IANA timezone string. -func buildCourseTimezoneMap(courses []map[string]interface{}) map[uint]string { - courseTimezones := make(map[uint]string, len(courses)) - for _, course := range courses { - if idFloat, ok := course["id"].(float64); ok { - if tz, ok := course["time_zone"].(string); ok && tz != "" { - courseTimezones[uint(idFloat)] = tz - } - } - } - return courseTimezones -} - -// canvasMappedUser holds the local user record for a Canvas external_user_id. -type canvasMappedUser struct { - UserID uint - NameFirst string - NameLast string - DocID string -} - -// fetchCanvasMappedUsers resolves a slice of Canvas external user ID strings to -// their local user records via provider_user_mappings. When facilityID is non-zero -// only users in that facility are returned. Returns a map keyed by external_user_id. -func (srv *server) fetchCanvasMappedUsers(providerID uint, canvasUserIDs []string, facilityID uint) (map[string]canvasMappedUser, error) { - if len(canvasUserIDs) == 0 { - return make(map[string]canvasMappedUser), nil - } - rows, err := srv.providerPlatforms.MappedUsers(context.Background(), providerID, canvasUserIDs, facilityID) - if err != nil { - return nil, newDBError(err, "error getting canvas_mapped_users") - } - result := make(map[string]canvasMappedUser, len(rows)) - for _, r := range rows { - result[r.ExternalUserID] = canvasMappedUser{ - UserID: r.UserID, - NameFirst: r.NameFirst, - NameLast: r.NameLast, - DocID: r.DocID, - } - } - return result, nil -} - -// fetchCanvasCourseEnrolleeIDs fetches active student enrollments for a Canvas -// course and returns the Canvas user IDs as strings, ready to query -// provider_user_mappings.external_user_id. -func (srv *server) fetchCanvasCourseEnrolleeIDs(provider *providerplatforms.Provider, rawCourseID uint) ([]string, error) { - ids, _, err := srv.fetchCanvasCourseEnrollmentData(provider, rawCourseID) - return ids, err -} - -// fetchCanvasCourseEnrollmentData fetches active student enrollments for a Canvas -// course and returns both the Canvas user IDs and a map of userID → enrollment created_at. -func (srv *server) fetchCanvasCourseEnrollmentData(provider *providerplatforms.Provider, rawCourseID uint) ([]string, map[string]*time.Time, error) { - enrollURL := fmt.Sprintf( - "%s/api/v1/courses/%d/enrollments?type[]=StudentEnrollment&state[]=active&per_page=100", - provider.BaseURL, rawCourseID, - ) - enrollments, err := srv.fetchAllCanvasPages(context.Background(), provider, enrollURL, 0) - if err != nil { - return nil, nil, err - } - ids := make([]string, 0, len(enrollments)) - dates := make(map[string]*time.Time, len(enrollments)) - for _, e := range enrollments { - if u, ok := e["user"].(map[string]interface{}); ok { - if id, ok := u["id"].(float64); ok { - userIDStr := fmt.Sprintf("%d", int(id)) - ids = append(ids, userIDStr) - if createdAtStr, ok := e["created_at"].(string); ok && createdAtStr != "" { - if t, err := time.Parse(time.RFC3339, createdAtStr); err == nil { - dates[userIDStr] = &t - } - } - } - } - } - return ids, dates, nil -} - -// canvasProgramCacheTTL is how long a cached program entry is served before it -// is refreshed. -const canvasProgramCacheTTL = 5 * time.Minute - -// canvasCacheRetryDelay is how long a failed refresh is left alone before the -// next request retries it. -const canvasCacheRetryDelay = 30 * time.Second - -// retryableCacheTimestamp dates an entry so it reads as stale once -// canvasCacheRetryDelay has passed. A refresh that failed writes this rather -// than time.Now(), so the entry is neither a loading marker that never resolves -// nor an empty result served as fresh for a whole TTL. -func retryableCacheTimestamp(ttl time.Duration) time.Time { - return time.Now().Add(canvasCacheRetryDelay - ttl) -} - -// carryOverFailedProviders re-appends the rows a previous cache entry held for -// the providers that failed this refresh. Without it a provider being briefly -// unreachable drops its rows from the page and caches that absence as a -// complete result. -func carryOverFailedProviders[T any](fresh, previous []T, failedProviders []uint, programID func(T) uint) []T { - if len(failedProviders) == 0 || len(previous) == 0 { - return fresh - } - failed := make(map[uint]bool, len(failedProviders)) - for _, id := range failedProviders { - failed[CanvasProgramIDOffset+id] = true - } - for _, item := range previous { - if failed[programID(item)] { - fresh = append(fresh, item) - } - } - return fresh -} - -// warmCanvasProgramCache fires a background goroutine that fetches program data -// from Canvas and writes the result to NATS KV at cacheKey. An in-flight dedup -// guard (canvasInflight) prevents concurrent fetches for the same provider. -// previous is the stale entry being replaced, if there was one; a failed fetch -// restores it so the caller's loading marker does not outlive the attempt. -func (srv *server) warmCanvasProgramCache(provider *providerplatforms.Provider, facilityID uint, cacheKey string, previous *ProgramsOverviewTable) { - key := provider.ID - if _, loaded := srv.canvasInflight.LoadOrStore(key, struct{}{}); loaded { - return - } - kv := srv.buckets[CanvasPrograms] - providerCopy := *provider - go func() { - defer srv.canvasInflight.Delete(key) - result, err := srv.fetchCanvasProviderProgram(&providerCopy, facilityID) - if err != nil { - log.WithError(err).Warnf("warmCanvasProgramCache: failed to fetch canvas program for provider %d", providerCopy.ID) - // The caller wrote a loading marker before firing this off. Replace it, - // or the reads below return that marker ahead of any freshness check and - // the view stays in the loading state until the entry expires. - srv.restoreCanvasProgram(cacheKey, previous) - return - } - if kv == nil { - return - } - cached := CachedCanvasProgram{Program: result, LastUpdated: time.Now(), Loading: false} - data, err := json.Marshal(cached) - if err != nil { - return - } - if _, err := kv.Put(cacheKey, data); err != nil { - log.WithError(err).Warnf("warmCanvasProgramCache: failed to store canvas program for provider %d", providerCopy.ID) - return - } - // Kick off completion-rate computation in the background. - // Uses the global (facility=0) key so all callers share the result. - globalKey := fmt.Sprintf("canvas_program_%d_0", providerCopy.ID) - go func(p providerplatforms.Provider, gKey string) { - rateCtx, rateCancel := context.WithTimeout(context.Background(), 90*time.Second) - defer rateCancel() - log.Debugf("background: starting completion rate computation for provider %d", p.ID) - rate := srv.computeCanvasCompletionRate(rateCtx, &p) - if rate != nil { - log.Debugf("background: completion rate for provider %d = %.2f%%", p.ID, *rate) - } else { - log.Debugf("background: no completion rate data for provider %d", p.ID) - } - entry, err := kv.Get(gKey) - if err != nil { - return - } - var c CachedCanvasProgram - if json.Unmarshal(entry.Value(), &c) != nil { - return - } - c.CompletionRate = rate - if d, err := json.Marshal(c); err == nil { - if _, err := kv.Put(gKey, d); err != nil { - log.WithError(err).Warnf("background: failed to update completion rate for provider %d", p.ID) - } - } - log.Debugf("background: completion rate cached for provider %d", p.ID) - }(providerCopy, globalKey) - }() -} - -// restoreCanvasProgram puts a failed refresh back into a retryable state: the -// stale entry it was replacing, dated so the next request refreshes it, or no -// entry at all when there is nothing worth serving. -func (srv *server) restoreCanvasProgram(cacheKey string, previous *ProgramsOverviewTable) { - kv := srv.buckets[CanvasPrograms] - if kv == nil { - return - } - if previous == nil { - if err := kv.Delete(cacheKey); err != nil { - log.WithError(err).Warnf("restoreCanvasProgram: failed to clear loading marker at %s", cacheKey) - } - return - } - cached := CachedCanvasProgram{ - Program: *previous, - LastUpdated: retryableCacheTimestamp(canvasProgramCacheTTL), - } - data, err := json.Marshal(cached) - if err != nil { - return - } - if _, err := kv.Put(cacheKey, data); err != nil { - log.WithError(err).Warnf("restoreCanvasProgram: failed to restore entry at %s", cacheKey) - } -} - -// canvasPageSlice returns the Canvas rows that belong on the requested page. -// Canvas rows are synthetic (one per provider platform, not a `programs` row) -// and have no position in the SQL sort order, so the combined list puts them -// after every database row: a Canvas row at index i occupies combined-list -// position dbTotal+i. A page keeps only the rows whose positions land inside -// its own window, which is what makes meta.total and per_page agree on every -// page. -func canvasPageSlice(canvas []ProgramsOverviewTable, dbTotal int64, args *QueryContext) []ProgramsOverviewTable { - if args.All { - return canvas - } - count := int64(len(canvas)) - start := min(max(int64(args.CalcOffset())-dbTotal, 0), count) - end := min(max(int64(args.CalcOffset()+args.PerPage)-dbTotal, 0), count) - if start >= end { - return nil - } - return canvas[start:end] -} - -// filterCanvasPrograms applies the same search/filter predicates that -// GetProgramsOverviewTable applies in SQL, in memory, to the synthetic -// per-provider rows getCanvasProviderPrograms returns. Canvas rows have no -// backing `programs` row, so the database-layer WHERE clauses never see them -// — without this they bypass every filter and are always counted in -// meta.total regardless of what actually matched. -// -// A filter with no meaningful counterpart on a Canvas row (credit type, -// funding type, capacity utilization, completion/attendance rate, the -// dept-admin facility_id filter) excludes the row: a row that cannot satisfy -// a predicate should not appear to match it. -func filterCanvasPrograms(programs []ProgramsOverviewTable, args *QueryContext, filters map[string]string) []ProgramsOverviewTable { - kept := make([]ProgramsOverviewTable, 0, len(programs)) -programLoop: - for _, p := range programs { - if args.Search != "" { - q := strings.ToLower(args.Search) - if !strings.Contains(strings.ToLower(p.ProgramName), q) && - !strings.Contains(strings.ToLower(p.Description), q) { - continue - } - } - for col, val := range filters { - switch col { - case "programs.is_active": - if !canvasStatusMatches(val) { - continue programLoop - } - case "pt.program_types": - if !canvasTypesMatch(p.Types, val) { - continue programLoop - } - case "mr.total_active_classes": - if !canvasNumericMatches(p.TotalActiveClasses, val) { - continue programLoop - } - case "mr.total_active_enrollments": - if !canvasNumericMatches(p.TotalActiveEnrollments, val) { - continue programLoop - } - case "mr.total_classes": - if !canvasNumericMatches(p.TotalClasses, val) { - continue programLoop - } - case "mr.total_enrollments": - if !canvasNumericMatches(p.TotalEnrollments, val) { - continue programLoop - } - case "pct.credit_types", "programs.funding_type", "capacity_utilization", - "completion_rate", "attendance_rate", "mr.total_active_facilities", "facility_id": - continue programLoop - } - } - kept = append(kept, p) - } - return kept -} - -// canvasStatusMatches reports whether a Canvas provider row satisfies a -// `programs.is_active` filter. Canvas rows always represent a live, -// unarchived connection, so they only ever match "Available". -func canvasStatusMatches(val string) bool { - return slices.Contains(strings.Split(val, "|"), "Available") -} - -// canvasTypesMatch mirrors the SQL side's `pt.program_types ~* val` regex -// match against the Canvas row's single hardcoded type ("College"). -func canvasTypesMatch(types, val string) bool { - re, err := regexp.Compile("(?i)" + val) - if err != nil { - return false - } - return re.MatchString(types) -} - -// canvasNumericMatches evaluates a `filter_mr.total_*`-style operator/value -// pair against a Canvas row's metric. A malformed value parses to no -// operator, same as the SQL-side applyNumericFilter, and is treated the same -// way: the filter doesn't apply, so the row is not excluded by it. A nil -// metric (the row is still loading and has no value yet) cannot satisfy an -// otherwise well-formed filter, so it is excluded. -func canvasNumericMatches(value *int64, val string) bool { - op, num := ParseOperatorAndValue(val) - if op == "" { - return true - } - if value == nil { - return false - } - v := float64(*value) - switch op { - case "=": - return v == num - case "!=": - return v != num - case ">=": - return v >= num - case "<=": - return v <= num - case ">": - return v > num - case "<": - return v < num - default: - return false - } -} - -// getCanvasProviderPrograms returns one synthetic ProgramsOverviewTable entry per -// enabled Canvas provider platform. Results are served from NATS KV when fresh -// (< 5 min old). On cache miss or stale entry a loading placeholder is written -// to KV and warmCanvasProgramCache is fired in the background, returning a -// Loading=true entry so the frontend can poll until data is ready. -// When adminRole is FacilityAdmin, enrollment counts are scoped to facilityID. -func (srv *server) getCanvasProviderPrograms(facilityID uint, adminRole users.Role) ([]ProgramsOverviewTable, error) { - providers, err := srv.activeProviders(context.Background()) - if err != nil { - return nil, err - } - kv := srv.buckets[CanvasPrograms] - if kv == nil { - log.Warn("canvas_programs NATS bucket is nil, skipping cache") - } - var result []ProgramsOverviewTable - - scopedFacilityID := uint(0) - if adminRole == users.FacilityAdmin { - scopedFacilityID = facilityID - } - - for _, provider := range providers { - if !isLiveProgramProvider(&provider) { - continue - } - cacheKey := fmt.Sprintf("canvas_program_%d_%d", provider.ID, scopedFacilityID) - - var previous *ProgramsOverviewTable - if kv != nil { - if entry, err := kv.Get(cacheKey); err == nil { - var cached CachedCanvasProgram - if json.Unmarshal(entry.Value(), &cached) == nil { - if cached.Loading { - // Computation already in progress — return placeholder. - placeholder := cached.Program - placeholder.Loading = true - result = append(result, placeholder) - continue - } - if cached.LastUpdated.Add(canvasProgramCacheTTL).After(time.Now()) { - result = append(result, cached.Program) - continue - } - stale := cached.Program - previous = &stale - } - } - } - - // Cache miss or stale — write a loading placeholder and kick off background fetch. - if kv != nil { - placeholder := ProgramsOverviewTable{ - ProgramID: CanvasProgramIDOffset + provider.ID, - ProgramName: provider.Name, - Source: providerSourceLabel(&provider), - Status: true, - Loading: true, - } - if data, err := json.Marshal(CachedCanvasProgram{Program: placeholder, LastUpdated: time.Now(), Loading: true}); err == nil { - if _, err := kv.Put(cacheKey, data); err != nil { - log.WithError(err).Warn("getCanvasProviderPrograms: failed to write loading placeholder") - } - } - } - providerCopy := provider - srv.warmCanvasProgramCache(&providerCopy, scopedFacilityID, cacheKey, previous) - result = append(result, ProgramsOverviewTable{ - ProgramID: CanvasProgramIDOffset + provider.ID, - ProgramName: provider.Name, - Source: providerSourceLabel(&provider), - Status: true, - Loading: true, - }) - } - return result, nil -} - -// fetchCanvasProviderProgram calls: -// -// GET /api/v1/accounts/{accountID}/courses?per_page=100 -// -// and builds the synthetic program entry. When facilityID is non-zero, enrollment -// counts are scoped to users belonging to that facility. -// active enrollment is the sum of per-course mapped enrollees (matching the detail -// page calculation), while total enrollment is the all-time provider_user_mappings count. -func (srv *server) fetchCanvasProviderProgram(provider *providerplatforms.Provider, facilityID uint) (ProgramsOverviewTable, error) { - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return ProgramsOverviewTable{}, err - } - ctx := context.Background() - listing, err := reader.ListCourses(ctx) - if err != nil { - return ProgramsOverviewTable{}, err - } - - // Class counts are reported against everything the provider returned, so a - // record we could not parse still counts toward the total. - totalClasses := int64(listing.Total) - var activeClasses int64 - for _, course := range listing.Courses { - if course.status == Active { - activeClasses++ - } - } - - // Sum per-course mapped enrollee counts concurrently, scoped to facility when set. - // This matches the calculation used on the program detail page. - var activeEnrollments int64 - var mu sync.Mutex - var wg sync.WaitGroup - for _, course := range listing.Courses { - if course.status != Active { - continue - } - wg.Add(1) - go func(c liveCourse) { - defer wg.Done() - var n int64 - if facilityID != 0 { - n = srv.countMappedCanvasEnrolleesForFacility(ctx, reader, provider, c, facilityID) - } else { - n = srv.countMappedCanvasEnrollees(ctx, reader, provider, c) - } - mu.Lock() - activeEnrollments += n - mu.Unlock() - }(course) - } - wg.Wait() - - programID := CanvasProgramIDOffset + provider.ID - return ProgramsOverviewTable{ - ProgramID: programID, - ProgramName: provider.Name, - Description: "Courses pulled live from " + providerDisplayName(provider) + " connection: " + provider.Name, - TotalEnrollments: &activeEnrollments, - TotalActiveEnrollments: &activeEnrollments, - TotalClasses: &totalClasses, - TotalActiveClasses: &activeClasses, - Types: "College", - Status: true, - Source: providerSourceLabel(provider), - }, nil -} - -// fetchAllCanvasPages fetches pages from a Canvas API endpoint and returns the combined results. -// max limits the total number of items returned; 0 means no limit (fetch all pages). -func (srv *server) fetchAllCanvasPages(ctx context.Context, provider *providerplatforms.Provider, startURL string, max int) ([]map[string]interface{}, error) { - var all []map[string]interface{} - for pageURL := startURL; pageURL != ""; { - // Checked every iteration, not just on startURL: the next page comes from - // the provider's own Link header. - if err := requireHTTPS(pageURL); err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, "GET", pageURL, nil) - if err != nil { - return nil, err - } - req.Header.Add("Authorization", "Bearer "+provider.AccessKey) - req.Header.Add("Accept", "application/json") - resp, err := srv.Client.Do(req) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - return nil, fmt.Errorf("canvas API returned %d", resp.StatusCode) - } - var page []map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { - _ = resp.Body.Close() - return nil, err - } - pageURL = NextPageURL(resp.Header.Get("Link")) - _ = resp.Body.Close() - all = append(all, page...) - if max > 0 && len(all) >= max { - break - } - } - return all, nil -} - -// NextPageURL parses an RFC 5988 Link header and returns the URL for rel="next", -// or an empty string if there is no next page. -// It handles quoted strings, varied whitespace, arbitrary attribute order, -// case-insensitive rel tokens, and URIs that contain commas or semicolons. -func NextPageURL(header string) string { - for _, entry := range splitLinkEntries(header) { - entry = strings.TrimSpace(entry) - if !strings.HasPrefix(entry, "<") { - continue - } - uriEnd := strings.IndexByte(entry, '>') - if uriEnd < 0 { - continue - } - uri := entry[1:uriEnd] - if linkEntryHasRelNext(entry[uriEnd+1:]) { - return uri - } - } - return "" -} - -// splitLinkEntries splits a Link header value by top-level commas (i.e. commas -// not inside angle brackets or double-quoted strings). -func splitLinkEntries(header string) []string { - var entries []string - depth, start := 0, 0 - inQuote := false - for i := 0; i < len(header); i++ { - switch header[i] { - case '<': - if !inQuote { - depth++ - } - case '>': - if !inQuote && depth > 0 { - depth-- - } - case '"': - inQuote = !inQuote - case '\\': - if inQuote && i+1 < len(header) { - i++ // skip escaped character inside quoted string - } - case ',': - if depth == 0 && !inQuote { - entries = append(entries, header[start:i]) - start = i + 1 - } - } - } - return append(entries, header[start:]) -} - -// linkEntryHasRelNext reports whether the parameter portion of a Link entry -// contains a rel attribute whose value includes the token "next" -// (case-insensitive; handles quoted values and multiple space-separated tokens). -func linkEntryHasRelNext(params string) bool { - inQuote, start := false, 0 - checkParam := func(p string) bool { - p = strings.TrimSpace(p) - eq := strings.IndexByte(p, '=') - if eq < 0 || !strings.EqualFold(strings.TrimSpace(p[:eq]), "rel") { - return false - } - val := strings.TrimSpace(p[eq+1:]) - if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' { - val = val[1 : len(val)-1] - } - for _, tok := range strings.Fields(val) { - if strings.EqualFold(tok, "next") { - return true - } - } - return false - } - for i := 0; i < len(params); i++ { - switch params[i] { - case '"': - inQuote = !inQuote - case '\\': - if inQuote && i+1 < len(params) { - i++ - } - case ';': - if !inQuote { - if checkParam(params[start:i]) { - return true - } - start = i + 1 - } - } - } - return checkParam(params[start:]) -} - -// fetchCanvasCalendarEvents fetches individual Canvas calendar events for all -// courses in the given provider for the specified date range. -func (srv *server) fetchCanvasCalendarEvents( - provider *providerplatforms.Provider, - start, end time.Time, - loc *time.Location, -) ([]FacilityProgramClassEvent, error) { - // Step 1: fetch all courses for this provider (paginated) - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return nil, err - } - listing, err := reader.ListCourses(context.Background()) - if err != nil { - return nil, fmt.Errorf("fetching courses for provider %d: %w", provider.ID, err) - } - - if listing.Total == 0 { - return []FacilityProgramClassEvent{}, nil - } - - courseTimezones := listing.Timezones - - // Providers that describe a weekly meeting pattern rather than dated calendar - // events have no calendar endpoint to call, so their pattern is expanded onto - // the same facility calendar. - if !isCanvasProvider(provider) { - return weeklyScheduleFacilityEvents(provider, listing.Courses, start, end, loc), nil - } - - // Step 2: build context_codes[] query params using url.Values so brackets are encoded - params := url.Values{} - params.Set("start_date", start.Format("2006-01-02")) - params.Set("end_date", end.Format("2006-01-02")) - params.Set("per_page", "100") - for _, course := range listing.Courses { - params.Add("context_codes[]", fmt.Sprintf("course_%d", course.rawID)) - } - rawEvents, err := srv.fetchAllCanvasPages(context.Background(), provider, provider.BaseURL+"/api/v1/calendar_events?"+params.Encode(), 0) - if err != nil { - return nil, fmt.Errorf("canvas API error fetching calendar events for provider %d: %w", provider.ID, err) - } - - var events []FacilityProgramClassEvent - for _, event := range rawEvents { - startAtStr, _ := event["start_at"].(string) - endAtStr, _ := event["end_at"].(string) - if startAtStr == "" || endAtStr == "" { - continue - } - startAt, err := time.Parse(time.RFC3339, startAtStr) - if err != nil { - continue - } - endAt, err := time.Parse(time.RFC3339, endAtStr) - if err != nil { - continue - } - - title, _ := event["title"].(string) - isCancelled := event["workflow_state"] == "deleted" - - var id uint - if idFloat, ok := event["id"].(float64); ok { - id = encodeCanvasClassID(provider.ID, uint(idFloat)) - } - - var classID uint - var canvasTimezone string - if contextCode, ok := event["context_code"].(string); ok && strings.HasPrefix(contextCode, "course_") { - if rawCourseID, err := strconv.ParseUint(strings.TrimPrefix(contextCode, "course_"), 10, 64); err == nil { - classID = encodeCanvasClassID(provider.ID, uint(rawCourseID)) - canvasTimezone = courseTimezones[uint(rawCourseID)] - } - } - - ev := FacilityProgramClassEvent{ - IsCanvasEvent: true, - Source: providerSourceLabel(provider), - IsCancelled: isCancelled, - ProgramID: CanvasProgramIDOffset + provider.ID, - ProgramName: provider.Name, - ClassName: title, - StartTime: &startAt, - EndTime: &endAt, - ClassStatus: Active, - CanvasTimezone: canvasTimezone, - } - ev.ID = id - ev.CohortID = classID - events = append(events, ev) - } - return events, nil -} - -// weeklyScheduleFacilityEvents expands every course's weekly meeting pattern into -// facility calendar events across [start, end]. Occurrences of a class share the -// class's synthetic ID -- the calendar renders from the event objects themselves -// and never looks one up by ID. -func weeklyScheduleFacilityEvents( - provider *providerplatforms.Provider, - courses []liveCourse, - start, end time.Time, - loc *time.Location, -) []FacilityProgramClassEvent { - var events []FacilityProgramClassEvent - for _, course := range courses { - // A weekly pattern has no end of its own, so a completed class would keep - // emitting meetings forever -- including ones dated after it finished. Any - // other status is emitted and carried through on the event itself. - if course.status == Completed { - continue - } - classID := encodeCanvasClassID(provider.ID, course.rawID) - for _, occurrence := range expandWeeklySchedule(course, start, end, loc) { - ev := FacilityProgramClassEvent{ - IsCanvasEvent: true, - Source: providerSourceLabel(provider), - ProgramID: CanvasProgramIDOffset + provider.ID, - ProgramName: provider.Name, - ClassName: course.name, - StartTime: &occurrence.StartAt, - EndTime: &occurrence.EndAt, - ClassStatus: course.status, - // Without this the calendar would re-interpret the wall-clock time - // in the viewer's own zone and shift every meeting. - CanvasTimezone: occurrence.Timezone, - } - ev.ID = classID - ev.CohortID = classID - events = append(events, ev) - } - } - return events -} - -// fetchCanvasClassesAllProviders returns a ProgramClassCohort for every Canvas course -// across all active Canvas provider platforms. Failures per-provider are logged -// and skipped so a single unreachable provider doesn't break the response; the -// IDs of those providers are returned so the caller can tell a complete listing -// from a partial one. -// facilityID must be non-nil and non-zero; for the statewide (all-facilities) case -// use fetchCanvasClassesAllProvidersAllFacilities instead. -func (srv *server) fetchCanvasClassesAllProviders(facilityID *uint) ([]ProgramClassCohort, []uint, error) { - providers, err := srv.activeProviders(context.Background()) - if err != nil { - return nil, nil, err - } - var facility *Facility - if facilityID != nil && *facilityID != 0 { - if f, err := srv.store.GetFacilityByID(int(*facilityID)); err == nil { - facility = f - } - } - var result []ProgramClassCohort - var failed []uint - for _, provider := range providers { - if !isLiveProgramProvider(&provider) { - continue - } - programID := CanvasProgramIDOffset + provider.ID - reader, err := srv.newLiveProgramProvider(&provider) - if err != nil { - log.WithError(err).Warnf("no live reader for provider %d, skipping", provider.ID) - failed = append(failed, provider.ID) - continue - } - ctx := context.Background() - listing, err := reader.ListCourses(ctx) - if err != nil { - log.WithError(err).Warnf("failed to fetch canvas courses for provider %d, skipping", provider.ID) - failed = append(failed, provider.ID) - continue - } - entries := listing.Courses - if facilityID == nil || *facilityID == 0 { - continue - } - counts := make([]int64, len(entries)) - var wg sync.WaitGroup - var mu sync.Mutex - sem := make(chan struct{}, 10) - for i := range entries { - wg.Add(1) - sem <- struct{}{} - go func(idx int) { - defer wg.Done() - defer func() { <-sem }() - n := srv.countMappedCanvasEnrolleesForFacility(ctx, reader, &provider, entries[idx], *facilityID) - mu.Lock() - counts[idx] = n - mu.Unlock() - }(i) - } - wg.Wait() - for i, entry := range entries { - result = append(result, ProgramClassCohort{ - DatabaseFields: DatabaseFields{ID: encodeFacilityCanvasClassID(*facilityID, provider.ID, entry.rawID)}, - ProgramID: programID, - FacilityID: *facilityID, - Facility: facility, - // Canvas courses have no row in the class tier, so nothing joins - // class_name for them. Set it from the course title so the field is - // populated for every cohort the API returns, Canvas or not. - ClassName: entry.name, - Description: entry.description, - StartDt: entry.startDt, - EndDt: entry.endDt, - Status: entry.status, - Enrolled: counts[i], - IsCanvas: true, - Source: providerSourceLabel(&provider), - Program: &Program{ - DatabaseFields: DatabaseFields{ID: programID}, - Name: "College - " + provider.Name, - }, - }) - } - } - return result, failed, nil -} - -// fetchCanvasClassesAllProvidersAllFacilities returns one ProgramClassCohort per -// (facility × Canvas course) with facility-scoped IDs. Used by the statewide -// classes index when facility=all so every returned class has a navigable, -// facility-scoped ID. -func (srv *server) fetchCanvasClassesAllProvidersAllFacilities() ([]ProgramClassCohort, []uint, error) { - providers, err := srv.activeProviders(context.Background()) - if err != nil { - return nil, nil, err - } - facilities, err := srv.store.GetAllFacilitiesOrdered() - if err != nil { - return nil, nil, err - } - var result []ProgramClassCohort - var failed []uint - for _, provider := range providers { - if !isLiveProgramProvider(&provider) { - continue - } - programID := CanvasProgramIDOffset + provider.ID - reader, err := srv.newLiveProgramProvider(&provider) - if err != nil { - log.WithError(err).Warnf("no live reader for provider %d, skipping", provider.ID) - failed = append(failed, provider.ID) - continue - } - ctx := context.Background() - listing, err := reader.ListCourses(ctx) - if err != nil { - log.WithError(err).Warnf("failed to fetch canvas courses for provider %d, skipping", provider.ID) - failed = append(failed, provider.ID) - continue - } - entries := listing.Courses - // Fetch per-facility enrollment counts for each course concurrently. - // countMappedCanvasEnrolleesPerFacility returns counts for all facilities at - // once, so this is O(courses) rather than O(courses × facilities). - facilityCounts := make([]map[uint]int64, len(entries)) - var wg sync.WaitGroup - var mu sync.Mutex - sem := make(chan struct{}, 10) - for i := range entries { - wg.Add(1) - sem <- struct{}{} - go func(idx int) { - defer wg.Done() - defer func() { <-sem }() - counts := srv.countMappedCanvasEnrolleesPerFacility(ctx, reader, &provider, entries[idx]) - mu.Lock() - facilityCounts[idx] = counts - mu.Unlock() - }(i) - } - wg.Wait() - for _, facility := range facilities { - facilityPtr := facility - for i, entry := range entries { - enrolled := int64(0) - if facilityCounts[i] != nil { - enrolled = facilityCounts[i][facility.ID] - } - result = append(result, ProgramClassCohort{ - DatabaseFields: DatabaseFields{ID: encodeFacilityCanvasClassID(facility.ID, provider.ID, entry.rawID)}, - ProgramID: programID, - FacilityID: facility.ID, - Facility: &facilityPtr, - ClassName: entry.name, // see fetchCanvasClassesAllProviders - Description: entry.description, - StartDt: entry.startDt, - EndDt: entry.endDt, - Status: entry.status, - Enrolled: enrolled, - IsCanvas: true, - Source: providerSourceLabel(&provider), - Program: &Program{ - DatabaseFields: DatabaseFields{ID: programID}, - Name: "College - " + provider.Name, - }, - }) - } - } - } - return result, failed, nil -} - -// appendCanvasEventsForFacility iterates all active Canvas provider platforms -// and collects calendar events for the given date range. Providers that could -// not be read are logged, skipped, and returned so the caller can tell a -// complete listing from a partial one. -func (srv *server) appendCanvasEventsForFacility(dtRng *DateRange, facilityID uint) ([]FacilityProgramClassEvent, []uint, error) { - loc := srv.facilityLocation(facilityID) - providers, err := srv.activeProviders(context.Background()) - if err != nil { - return nil, nil, err - } - var result []FacilityProgramClassEvent - var failed []uint - for _, provider := range providers { - if !isLiveProgramProvider(&provider) { - continue - } - evs, err := srv.fetchCanvasCalendarEvents(&provider, dtRng.Start, dtRng.End, loc) - if err != nil { - log.WithError(err).Warnf("failed to fetch canvas calendar events for provider %d, skipping", provider.ID) - failed = append(failed, provider.ID) - continue - } - result = append(result, evs...) - } - return result, failed, nil -} - -func (srv *server) handleShowCanvasProgram(w http.ResponseWriter, r *http.Request, log sLog, programID uint) error { - connectionID := programID - CanvasProgramIDOffset - provider, err := srv.providerByID(r.Context(), connectionID) - if err != nil { - return newDatabaseServiceError(err) - } - if !isLiveProgramProvider(provider) { - return newInvalidIdServiceError(fmt.Errorf("provider %d is not a live program provider", connectionID), "program ID") - } - claims := claimsFrom(r) - if !claims.HasFeature(features.ProviderPlatforms) { - return newInvalidIdServiceError(fmt.Errorf("provider access disabled for provider %d", connectionID), "program ID") - } - - facilityID := getQueryContext(r).FacilityID - cacheKey := fmt.Sprintf("canvas_program_%d_%d", connectionID, facilityID) - globalKey := fmt.Sprintf("canvas_program_%d_0", connectionID) - kv := srv.buckets[CanvasPrograms] - var previous *ProgramsOverviewTable - - // Try facility-scoped key first, then global key. - if kv != nil { - for _, key := range []string{cacheKey, globalKey} { - entry, err := kv.Get(key) - if err != nil { - continue - } - var cached CachedCanvasProgram - if json.Unmarshal(entry.Value(), &cached) != nil { - continue - } - if cached.Loading { - return writeJsonResponse(w, http.StatusOK, ProgramOverviewResponse{ - Program: Program{ - DatabaseFields: DatabaseFields{ID: programID}, - Name: "College - " + provider.Name, - IsActive: true, - ProgramTypes: []ProgramType{}, - ProgramCreditTypes: []ProgramCreditType{}, - Facilities: []Facility{}, - }, - ActiveClassFacilityIDs: []int{}, - Source: providerSourceLabel(provider), - Loading: true, - }) - } - if cached.LastUpdated.Add(canvasProgramCacheTTL).After(time.Now()) { - prog := cached.Program - totalEnrollments := 0 - if prog.TotalEnrollments != nil { - totalEnrollments = int(*prog.TotalEnrollments) - } - activeEnrollments := 0 - if prog.TotalActiveEnrollments != nil { - activeEnrollments = int(*prog.TotalActiveEnrollments) - } - return writeJsonResponse(w, http.StatusOK, ProgramOverviewResponse{ - Program: Program{ - DatabaseFields: DatabaseFields{ID: programID}, - Name: "College - " + provider.Name, - Description: prog.Description, - IsActive: true, - ProgramTypes: []ProgramType{}, - ProgramCreditTypes: []ProgramCreditType{}, - Facilities: []Facility{}, - }, - ActiveResidents: activeEnrollments, - ActiveEnrollments: activeEnrollments, - TotalEnrollments: totalEnrollments, - CompletionRate: cached.CompletionRate, - ActiveClassFacilityIDs: []int{}, - Source: providerSourceLabel(provider), - }) - } - stale := cached.Program - previous = &stale - break - } - } - - // Cache miss — write a loading placeholder, kick off warm, return loading response. - if kv != nil { - placeholder := ProgramsOverviewTable{ - ProgramID: programID, - ProgramName: "College - " + provider.Name, - Source: providerSourceLabel(provider), - Status: true, - Loading: true, - } - if data, err := json.Marshal(CachedCanvasProgram{Program: placeholder, LastUpdated: time.Now(), Loading: true}); err == nil { - if _, putErr := kv.Put(cacheKey, data); putErr != nil { - _ = putErr - } - } - } - srv.warmCanvasProgramCache(provider, facilityID, cacheKey, previous) - return writeJsonResponse(w, http.StatusOK, ProgramOverviewResponse{ - Program: Program{ - DatabaseFields: DatabaseFields{ID: programID}, - Name: "College - " + provider.Name, - IsActive: true, - ProgramTypes: []ProgramType{}, - ProgramCreditTypes: []ProgramCreditType{}, - Facilities: []Facility{}, - }, - ActiveClassFacilityIDs: []int{}, - Source: providerSourceLabel(provider), - Loading: true, - }) -} - -func (srv *server) handleGetCanvasClasses(w http.ResponseWriter, r *http.Request, log sLog, programID uint) error { - connectionID := programID - CanvasProgramIDOffset - provider, err := srv.providerByID(r.Context(), connectionID) - if err != nil { - return newDatabaseServiceError(err) - } - if !isLiveProgramProvider(provider) { - return newInvalidIdServiceError(fmt.Errorf("provider %d is not a live program provider", connectionID), "program ID") - } - claims := claimsFrom(r) - if !claims.HasFeature(features.ProviderPlatforms) { - return newInvalidIdServiceError(fmt.Errorf("provider access disabled for provider %d", connectionID), "program ID") - } - - facilityID := getQueryContext(r).FacilityID - if r.URL.Query().Get("all") == "true" || facilityID == 0 { - return srv.handleGetCanvasClassesByFacility(w, r, provider, programID, connectionID) - } - - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - listing, err := reader.ListCourses(r.Context()) - if err != nil { - return newInternalServerServiceError(err, "failed to fetch canvas courses") - } - - courseTimezones := listing.Timezones - - classes := make([]ProgramClassDetail, 0, len(listing.Courses)) - for _, entry := range listing.Courses { - classes = append(classes, ProgramClassDetail{ - ProgramClassCohort: ProgramClassCohort{ - DatabaseFields: DatabaseFields{ID: entry.encodedID}, - ProgramID: programID, - FacilityID: 0, - ClassName: entry.name, // see fetchCanvasClassesAllProviders - Description: entry.description, - StartDt: entry.startDt, - EndDt: entry.endDt, - Status: entry.status, - }, - FacilityName: provider.Name, - }) - } - - // Concurrently count mapped enrollees for each course, scoped to facility when set. - counts := make(map[uint]int64, len(classes)) - var mu sync.Mutex - var wg sync.WaitGroup - for i := range classes { - wg.Add(1) - go func(idx int) { - defer wg.Done() - var n int64 - if facilityID != 0 { - n = srv.countMappedCanvasEnrolleesForFacility(r.Context(), reader, provider, listing.Courses[idx], facilityID) - } else { - n = srv.countMappedCanvasEnrollees(r.Context(), reader, provider, listing.Courses[idx]) - } - mu.Lock() - counts[classes[idx].ID] = n - mu.Unlock() - }(i) - } - wg.Wait() - - for i := range classes { - n := counts[classes[i].ID] - classes[i].ProgramClassCohort.Enrolled = n - classes[i].Enrolled = int(n) - } - - // Batch-fetch upcoming calendar events to populate schedule info. - scheduleEvents := srv.fetchCanvasCoursesScheduleEvents(provider, listing.Courses, srv.facilityLocation(facilityID)) - facilityTimezone := getQueryContext(r).Timezone - for i, cls := range classes { - _, rawID := decodeCanvasClassID(cls.ID) - if evs := scheduleEvents[rawID]; len(evs) > 0 { - classes[i].Events = evs - tz := courseTimezones[rawID] - if tz == "" { - tz = facilityTimezone - } - sched, _ := FormatClassScheduleAndRoom(evs, tz) - classes[i].Schedule = sched - } - } - - // Swap non-scoped IDs for facility-scoped IDs now that all processing is done. - // This must happen last because enrollment counting and schedule lookups use - // decodeCanvasClassID on the old-style IDs. - if facilityID != 0 { - for i := range classes { - _, rawID := decodeCanvasClassID(classes[i].ID) - newID := encodeFacilityCanvasClassID(facilityID, connectionID, rawID) - classes[i].ID = newID - classes[i].FacilityID = facilityID - for j := range classes[i].Events { - classes[i].Events[j].CohortID = newID - } - } - } - - args := getQueryContext(r) - args.Total = int64(len(classes)) - return writePaginatedResponse(w, http.StatusOK, classes, args.IntoMeta()) -} - -// handleGetCanvasClassesByFacility returns one ProgramClassDetail per (facility × Canvas course) -// with per-facility enrollment counts, used by the statewide overview so each facility row -// expands to show all Canvas courses with enrollment scoped to that facility. -func (srv *server) handleGetCanvasClassesByFacility(w http.ResponseWriter, r *http.Request, provider *providerplatforms.Provider, programID, connectionID uint) error { - // Fetch all Canvas courses. - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - listing, err := reader.ListCourses(r.Context()) - if err != nil { - return newInternalServerServiceError(err, "failed to fetch canvas courses") - } - - courseTimezones := listing.Timezones - entries := listing.Courses - - // Get all facilities. - facilities, err := srv.store.GetAllFacilitiesOrdered() - if err != nil { - return newDatabaseServiceError(err) - } - - // Concurrently fetch per-facility enrollment counts for each course. - facilityCounts := make([]map[uint]int64, len(entries)) - var wg sync.WaitGroup - var mu sync.Mutex - for i := range entries { - wg.Add(1) - go func(idx int) { - defer wg.Done() - counts := srv.countMappedCanvasEnrolleesPerFacility(r.Context(), reader, provider, entries[idx]) - mu.Lock() - facilityCounts[idx] = counts - mu.Unlock() - }(i) - } - wg.Wait() - - // Canvas calendar events carry absolute times, so one fetch serves every - // facility. A weekly meeting pattern is wall clock in the facility's own zone, - // so it has to be rebuilt for each facility row below. - perFacilitySchedules := !isCanvasProvider(provider) - var scheduleEvents map[uint][]ProgramClassEvent - if !perFacilitySchedules { - scheduleEvents = srv.fetchCanvasCoursesScheduleEvents(provider, entries, nil) - } - - // Build one row per (facility × course). - classes := make([]ProgramClassDetail, 0, len(facilities)*len(entries)) - for _, facility := range facilities { - if perFacilitySchedules { - scheduleEvents = srv.fetchCanvasCoursesScheduleEvents(provider, entries, locationOrUTC(facility.Timezone)) - } - for i, entry := range entries { - enrolled := int64(0) - if facilityCounts[i] != nil { - enrolled = facilityCounts[i][facility.ID] - } - scopedID := encodeFacilityCanvasClassID(facility.ID, connectionID, entry.rawID) - var sched string - var evSlice []ProgramClassEvent - if evs := scheduleEvents[entry.rawID]; len(evs) > 0 { - // Cloned because a Canvas listing shares one slice across every - // facility row, and each row scopes the events to its own class ID. - evSlice = slices.Clone(evs) - for j := range evSlice { - evSlice[j].CohortID = scopedID - } - tz := courseTimezones[entry.rawID] - if tz == "" { - tz = facility.Timezone - } - sched, _ = FormatClassScheduleAndRoom(evSlice, tz) - } - classes = append(classes, ProgramClassDetail{ - ProgramClassCohort: ProgramClassCohort{ - DatabaseFields: DatabaseFields{ID: scopedID}, - ProgramID: programID, - FacilityID: facility.ID, - ClassName: entry.name, // see fetchCanvasClassesAllProviders - Description: entry.description, - StartDt: entry.startDt, - EndDt: entry.endDt, - Status: entry.status, - Enrolled: enrolled, - Events: evSlice, - }, - FacilityName: facility.Name, - Enrolled: int(enrolled), - Schedule: sched, - }) - } - } - - args := getQueryContext(r) - args.Total = int64(len(classes)) - return writePaginatedResponse(w, http.StatusOK, classes, args.IntoMeta()) -} - -// weekdayToRRuleDay converts a time.Weekday to the two-letter iCalendar abbreviation. -func weekdayToRRuleDay(day time.Weekday) string { - switch day { - case time.Monday: - return "MO" - case time.Tuesday: - return "TU" - case time.Wednesday: - return "WE" - case time.Thursday: - return "TH" - case time.Friday: - return "FR" - case time.Saturday: - return "SA" - default: - return "SU" - } -} - -// buildCanvasEventRRule builds an iCalendar RRULE string and a Go duration string -// from a Canvas calendar event's start/end times and optional rrule fragment. -// When canvasRRule is present (e.g. "FREQ=WEEKLY;BYDAY=MO") it is combined with a -// DTSTART derived from startAt. When absent the event is treated as a single occurrence. -func buildCanvasEventRRule(startAt, endAt time.Time, canvasRRule string) (recurrenceRule, duration string) { - duration = endAt.Sub(startAt).String() - dtstart := "DTSTART:" + startAt.UTC().Format("20060102T150405Z") - if canvasRRule != "" { - rrulePart := canvasRRule - if !strings.HasPrefix(rrulePart, "RRULE:") { - rrulePart = "RRULE:" + rrulePart - } - recurrenceRule = dtstart + "\n" + rrulePart - } else { - recurrenceRule = fmt.Sprintf("%s\nRRULE:FREQ=WEEKLY;BYDAY=%s;COUNT=1", dtstart, weekdayToRRuleDay(startAt.UTC().Weekday())) - } - return -} - -// weeklyScheduleClassEvents renders a recurring weekly meeting pattern as the -// synthetic ProgramClassEvents the class lists format with -// FormatClassScheduleAndRoom. A single rule carries one start time and -// one duration, so days that meet at the same time share a rule and every other -// distinct time -- including a second meeting on a day that already has one -- -// gets its own. Each DTSTART is anchored on the next occurrence of that rule's -// earliest day, so the rendered wall clock matches the pattern in the facility's -// own zone. -func weeklyScheduleClassEvents(course liveCourse, loc *time.Location, now time.Time) []ProgramClassEvent { - if len(course.weeklySchedule) == 0 { - return nil - } - if loc == nil { - loc = time.UTC - } - - type ruleGroup struct { - anchorDay time.Weekday - meeting meetingTime - byDay []string - } - var groups []*ruleGroup - byMeeting := make(map[meetingTime]*ruleGroup) - // Walk Sunday-to-Saturday rather than ranging the map, so the rules and their - // BYDAY lists are stable between calls. - for day := time.Sunday; day <= time.Saturday; day++ { - rruleDay := weekdayToRRuleDay(day) - for _, meeting := range course.weeklySchedule[day] { - group, ok := byMeeting[meeting] - if !ok { - group = &ruleGroup{anchorDay: day, meeting: meeting} - byMeeting[meeting] = group - groups = append(groups, group) - } - if len(group.byDay) == 0 || group.byDay[len(group.byDay)-1] != rruleDay { - group.byDay = append(group.byDay, rruleDay) - } - } - } - if len(groups) == 0 { - return nil - } - - local := now.In(loc) - midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc) - events := make([]ProgramClassEvent, 0, len(groups)) - for _, group := range groups { - offset := (int(group.anchorDay) - int(local.Weekday()) + 7) % 7 - dtstart := midnight.AddDate(0, 0, offset).Add(time.Duration(group.meeting.startMin) * time.Minute) - events = append(events, ProgramClassEvent{ - RecurrenceRule: fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;BYDAY=%s", - dtstart.UTC().Format("20060102T150405Z"), strings.Join(group.byDay, ",")), - Duration: (time.Duration(group.meeting.endMin-group.meeting.startMin) * time.Minute).String(), - }) - } - return events -} - -// fetchCanvasCoursesScheduleEvents returns a map of rawCourseID → synthetic -// ProgramClassEvents carrying a RecurrenceRule and Duration suitable for -// FormatClassScheduleAndRoom, mirroring how regular class events work. -// Canvas is read from its calendar events endpoint; providers that describe a -// recurring weekly pattern instead have no such endpoint, so their schedule is -// built from the pattern the course listing already carried. A weekly pattern is -// wall clock in loc, so a caller building rows for more than one facility has to -// call this once per facility. -func (srv *server) fetchCanvasCoursesScheduleEvents( - provider *providerplatforms.Provider, - courses []liveCourse, - loc *time.Location, -) map[uint][]ProgramClassEvent { - result := make(map[uint][]ProgramClassEvent, len(courses)) - if len(courses) == 0 { - return result - } - if !isCanvasProvider(provider) { - now := time.Now() - for _, course := range courses { - if evs := weeklyScheduleClassEvents(course, loc, now); len(evs) > 0 { - result[course.rawID] = evs - } - } - return result - } - - rawCourseIDs := make([]uint, 0, len(courses)) - for _, course := range courses { - rawCourseIDs = append(rawCourseIDs, course.rawID) - } - - today := time.Now() - params := url.Values{} - params.Set("start_date", today.Format("2006-01-02")) - params.Set("end_date", today.AddDate(0, 0, 28).Format("2006-01-02")) - params.Set("per_page", "100") - params.Set("type", "event") - for _, id := range rawCourseIDs { - params.Add("context_codes[]", fmt.Sprintf("course_%d", id)) - } - - rawEvents, err := srv.fetchAllCanvasPages(context.Background(), provider, provider.BaseURL+"/api/v1/calendar_events?"+params.Encode(), 0) - if err != nil { - log.WithError(err).Warnf("fetchCanvasCoursesScheduleEvents: failed to fetch events for provider %d", provider.ID) - return result - } - - for _, event := range rawEvents { - contextCode, _ := event["context_code"].(string) - if !strings.HasPrefix(contextCode, "course_") { - continue - } - courseIDInt, err := strconv.ParseUint(strings.TrimPrefix(contextCode, "course_"), 10, 64) - if err != nil { - continue - } - rawID := uint(courseIDInt) - if _, seen := result[rawID]; seen { - continue // keep only the first (earliest) event per course - } - startAtStr, _ := event["start_at"].(string) - endAtStr, _ := event["end_at"].(string) - if startAtStr == "" || endAtStr == "" { - continue - } - startAt, err := time.Parse(time.RFC3339, startAtStr) - if err != nil { - continue - } - endAt, err := time.Parse(time.RFC3339, endAtStr) - if err != nil { - continue - } - canvasRRule, _ := event["rrule"].(string) - rruleStr, durStr := buildCanvasEventRRule(startAt, endAt, canvasRRule) - result[rawID] = []ProgramClassEvent{{ - RecurrenceRule: rruleStr, - Duration: durStr, - }} - } - return result -} - -// invalidateCanvasProgramCache deletes the NATS KV cache entries for the given Canvas -// provider so the next read recomputes enrollment counts. Both the global (scopedFacility=0) -// entry and the facility-scoped entry for the unlinked user's facility are purged. -// Non-Canvas providers and nil buckets are silently skipped. -func (srv *server) invalidateCanvasProgramCache(providerID uint, userID int) { - provider, err := srv.providerByID(context.Background(), providerID) - if err != nil || !isLiveProgramProvider(provider) { - return - } - kv := srv.buckets[CanvasPrograms] - if kv == nil { - return - } - globalKey := fmt.Sprintf("canvas_program_%d_0", providerID) - if err := kv.Delete(globalKey); err != nil { - log.WithError(err).Warnf("invalidateCanvasProgramCache: failed to delete key %s", globalKey) - } - user, err := srv.store.GetUserByID(uint(userID)) - if err == nil && user.FacilityID != 0 { - facilityKey := fmt.Sprintf("canvas_program_%d_%d", providerID, user.FacilityID) - if err := kv.Delete(facilityKey); err != nil { - log.WithError(err).Warnf("invalidateCanvasProgramCache: failed to delete key %s", facilityKey) - } - } -} - -// countMappedCanvasEnrolleesPerFacility returns a map of facility_id → count of -// mapped users from that facility for a course. Enrollees are resolved through -// courseEnrollees, so a provider that reports them inline with its course listing -// costs no extra request here. -func (srv *server) countMappedCanvasEnrolleesPerFacility(ctx context.Context, reader LiveProgramProvider, provider *providerplatforms.Provider, course liveCourse) map[uint]int64 { - canvasUserIDs, err := courseEnrollees(ctx, reader, course) - if err != nil { - log.WithError(err).Warnf("countMappedCanvasEnrolleesPerFacility: failed to fetch enrollments for course %d", course.rawID) - return nil - } - if len(canvasUserIDs) == 0 { - return map[uint]int64{} - } - // The count runs even if ctx's deadline passed during the Canvas fetch, so - // the enrollees fetched in time still count. - result, err := srv.providerPlatforms.CountMappedPerFacility(context.WithoutCancel(ctx), provider.ID, canvasUserIDs) - if err != nil { - log.WithError(err).Warnf("countMappedCanvasEnrolleesPerFacility: failed to count enrollees for course %d", course.rawID) - return nil - } - return result -} - -// decodeCanvasClassID recovers (providerID, rawCourseID) from an encoded Canvas class ID. -func decodeCanvasClassID(classID uint) (providerID uint, rawCourseID uint) { - remainder := classID - CanvasClassIDOffset - return remainder / 1_000_000, remainder % 1_000_000 -} - -// encodeFacilityCanvasClassID packs (facilityID, providerID, rawCourseID) into a -// facility-scoped synthetic class ID so detail/enrollment handlers can extract the -// facility from the ID itself without relying on a URL parameter. -// Limits: facilityID 0–999, providerID 0–999, rawCourseID 0–999_999. -func encodeFacilityCanvasClassID(facilityID, providerID, rawCourseID uint) uint { - return CanvasFacilityClassIDOffset + facilityID*1_000_000_000 + providerID*1_000_000 + rawCourseID -} - -// decodeFacilityCanvasClassID is the reverse of encodeFacilityCanvasClassID. -func decodeFacilityCanvasClassID(classID uint) (facilityID, providerID, rawCourseID uint) { - remainder := classID - CanvasFacilityClassIDOffset - facilityID = remainder / 1_000_000_000 - remainder = remainder % 1_000_000_000 - providerID = remainder / 1_000_000 - rawCourseID = remainder % 1_000_000 - return -} - -// resolveCanvasClassParts extracts (facilityID, providerID, rawCourseID) from any -// canvas class ID. For facility-scoped IDs the embedded facilityID is returned; -// for old-style IDs facilityID is 0 and the caller should fall back to JWT claims. -func resolveCanvasClassParts(classID uint) (facilityID, providerID, rawCourseID uint) { - if classID >= CanvasFacilityClassIDOffset { - return decodeFacilityCanvasClassID(classID) - } - p, c := decodeCanvasClassID(classID) - return 0, p, c -} - -// countMappedCanvasEnrolleesForFacility is like countMappedCanvasEnrollees but -// only counts users belonging to the given facility. -func (srv *server) countMappedCanvasEnrolleesForFacility(ctx context.Context, reader LiveProgramProvider, provider *providerplatforms.Provider, course liveCourse, facilityID uint) int64 { - canvasUserIDs, err := courseEnrollees(ctx, reader, course) - if err != nil { - log.WithError(err).Warnf("countMappedCanvasEnrolleesForFacility: failed to fetch enrollments for course %d", course.rawID) - return 0 - } - if len(canvasUserIDs) == 0 { - return 0 - } - // The count runs even if ctx's deadline passed during the Canvas fetch, so - // the enrollees fetched in time still count. - count, err := srv.providerPlatforms.CountMappedAtFacility(context.WithoutCancel(ctx), provider.ID, canvasUserIDs, facilityID) - if err != nil { - log.WithError(err).Warnf("countMappedCanvasEnrolleesForFacility: failed to count enrollees for course %d", course.rawID) - return 0 - } - return count -} - -// countMappedCanvasEnrollees returns how many of a course's students have a -// ProviderUserMapping. See countMappedCanvasEnrolleesPerFacility on how the -// enrollees are resolved. -func (srv *server) countMappedCanvasEnrollees(ctx context.Context, reader LiveProgramProvider, provider *providerplatforms.Provider, course liveCourse) int64 { - canvasUserIDs, err := courseEnrollees(ctx, reader, course) - if err != nil { - log.WithError(err).Warnf("countMappedCanvasEnrollees: failed to fetch enrollments for course %d", course.rawID) - return 0 - } - if len(canvasUserIDs) == 0 { - return 0 - } - // The count runs even if ctx's deadline passed during the Canvas fetch, so - // the enrollees fetched in time still count. - count, err := srv.providerPlatforms.CountMapped(context.WithoutCancel(ctx), provider.ID, canvasUserIDs) - if err != nil { - log.WithError(err).Warnf("countMappedCanvasEnrollees: failed to count enrollees for course %d", course.rawID) - return 0 - } - return count -} - -// computeCanvasCompletionRate fetches all enrollments across every course for -// the provider (active + completed states) and returns the percentage of mapped -// enrollments that have enrollment_state == "completed". -// ctx should carry a deadline — callers launching this in a goroutine must use -// context.WithTimeout to bound the N+1 Canvas API requests. -func (srv *server) computeCanvasCompletionRate(ctx context.Context, provider *providerplatforms.Provider) *float64 { - // The per-course loop below builds Canvas enrollment URLs and bearer auth by - // hand rather than going through the provider's transport, so running it for - // any other provider only spends a request per course to get an error back. - if !isCanvasProvider(provider) { - return nil - } - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - log.WithError(err).Warnf("canvas completion rate: cannot build provider client for provider %d", provider.ID) - return nil - } - listing, err := reader.ListCourses(ctx) - if err != nil { - log.WithError(err).Warnf("canvas completion rate: ListCourses failed for provider %d", provider.ID) - return nil - } - if len(listing.Courses) == 0 { - return nil - } - - type courseResult struct { - allIDs []string - completedIDs []string - } - results := make([]courseResult, len(listing.Courses)) - var wg sync.WaitGroup - var mu sync.Mutex - sem := make(chan struct{}, 10) - for i, course := range listing.Courses { - rawCourseID := course.rawID - wg.Add(1) - sem <- struct{}{} - go func(idx int, courseID uint) { - defer wg.Done() - defer func() { <-sem }() - enrollURL := fmt.Sprintf( - "%s/api/v1/courses/%d/enrollments?type[]=StudentEnrollment&state[]=active&state[]=completed&per_page=100", - provider.BaseURL, courseID, - ) - enrollments, err := srv.fetchAllCanvasPages(ctx, provider, enrollURL, 0) - if err != nil { - return - } - var all, completed []string - for _, e := range enrollments { - u, ok := e["user"].(map[string]interface{}) - if !ok { - continue - } - id, ok := u["id"].(float64) - if !ok { - continue - } - sid := fmt.Sprintf("%d", int(id)) - all = append(all, sid) - if state, _ := e["enrollment_state"].(string); state == "completed" { - completed = append(completed, sid) - } - } - mu.Lock() - results[idx] = courseResult{allIDs: all, completedIDs: completed} - mu.Unlock() - }(i, rawCourseID) - } - wg.Wait() - - var allIDs, completedIDs []string - for _, r := range results { - allIDs = append(allIDs, r.allIDs...) - completedIDs = append(completedIDs, r.completedIDs...) - } - if len(allIDs) == 0 { - return nil - } - - // The count runs even if ctx's deadline passed during the Canvas fetch, so - // the enrollees fetched in time still count. - countCtx := context.WithoutCancel(ctx) - totalMapped, err := srv.providerPlatforms.CountMapped(countCtx, provider.ID, allIDs) - if err != nil { - log.WithError(err).Warnf("canvas completion rate: counting mapped enrollees failed for provider %d", provider.ID) - return nil - } - if totalMapped == 0 { - return nil - } - var completedMapped int64 - if len(completedIDs) > 0 { - completedMapped, err = srv.providerPlatforms.CountMapped(countCtx, provider.ID, completedIDs) - if err != nil { - log.WithError(err).Warnf("canvas completion rate: counting completed mapped enrollees failed for provider %d", provider.ID) - return nil - } - } - rate := float64(completedMapped) / float64(totalMapped) * 100 - return &rate -} - -func (srv *server) handleGetCanvasClassDetail(w http.ResponseWriter, r *http.Request, log sLog, classID uint) error { - facilityIDFromID, providerID, rawCourseID := resolveCanvasClassParts(classID) - provider, err := srv.providerByID(r.Context(), providerID) - if err != nil { - return newDatabaseServiceError(err) - } - if !isLiveProgramProvider(provider) { - return newInvalidIdServiceError(fmt.Errorf("provider %d is not a live program provider", providerID), "class ID") - } - claims := claimsFrom(r) - if !claims.HasFeature(features.ProviderPlatforms) { - return newInvalidIdServiceError(fmt.Errorf("provider access disabled for provider %d", providerID), "class ID") - } - - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - entry, canvasTimezone, err := reader.GetCourse(r.Context(), rawCourseID) - if err != nil { - return newInternalServerServiceError(err, "failed to fetch course from "+providerDisplayName(provider)) - } - programID := CanvasProgramIDOffset + providerID - - // Use the facility embedded in the ID; fall back to JWT claims for old-style IDs. - facilityID := facilityIDFromID - if facilityID == 0 { - facilityID = getQueryContext(r).FacilityID - } - var enrolled int64 - if facilityID != 0 { - enrolled = srv.countMappedCanvasEnrolleesForFacility(r.Context(), reader, provider, entry, facilityID) - } else { - enrolled = srv.countMappedCanvasEnrollees(r.Context(), reader, provider, entry) - } - - var facility *Facility - if facilityID != 0 { - if f, err := srv.store.GetFacilityByID(int(facilityID)); err == nil { - facility = f - } - } - - scheduleEvents := srv.fetchCanvasCoursesScheduleEvents(provider, []liveCourse{entry}, srv.facilityLocation(facilityID)) - events := []ProgramClassEvent{} - if evs := scheduleEvents[rawCourseID]; len(evs) > 0 { - events = evs - } - - cls := ProgramClassCohort{ - DatabaseFields: DatabaseFields{ID: classID}, - ProgramID: programID, - FacilityID: facilityID, - Facility: facility, - ClassName: entry.name, // see fetchCanvasClassesAllProviders - Description: entry.description, - StartDt: entry.startDt, - EndDt: entry.endDt, - Status: entry.status, - Enrolled: enrolled, - IsCanvas: true, - Source: providerSourceLabel(provider), - CanvasTimezone: canvasTimezone, - Program: &Program{ - DatabaseFields: DatabaseFields{ID: programID}, - Name: "College - " + provider.Name, - }, - Events: events, - Enrollments: []ProgramClassEnrollment{}, - } - return writeJsonResponse(w, http.StatusOK, cls) -} - -// fetchCanvasCourseTimezone returns the IANA timezone string for a Canvas course, -// falling back to empty string on any error so callers can use a default. -func (srv *server) fetchCanvasCourseTimezone(provider *providerplatforms.Provider, rawCourseID uint) string { - transport, err := srv.transportFor(provider) - if err != nil { - return "" - } - course, err := transport.fetchOne(context.Background(), provider, canvasCourseURL(provider, rawCourseID)) - if err != nil { - return "" - } - tz, _ := course["time_zone"].(string) - return tz -} - -type canvasScheduleEvent struct { - ID uint `json:"id"` - Title string `json:"title"` - StartAt time.Time `json:"start_at"` - EndAt time.Time `json:"end_at"` - IsCancelled bool `json:"is_cancelled"` - Timezone string `json:"timezone,omitempty"` -} - -// expandWeeklySchedule turns a recurring weekly meeting pattern into concrete -// events across [start, end]. Providers that describe a weekly pattern rather -// than dated calendar events (Essential Ed) are rendered on the same calendar -// this way. Days outside the class's own open/close dates are skipped. -// The meeting times are wall-clock text with no zone attached -- an instructor -// typing "9:00 AM" means 9am where the class meets. loc is the facility's -// timezone, so the instants come out right for the people reading the calendar. -func expandWeeklySchedule(course liveCourse, start, end time.Time, loc *time.Location) []canvasScheduleEvent { - events := []canvasScheduleEvent{} - if len(course.weeklySchedule) == 0 { - return events - } - if loc == nil { - loc = time.UTC - } - for day := start; !day.After(end); day = day.AddDate(0, 0, 1) { - // The meeting below is emitted on day's calendar date in the facility's - // zone, while the provider reports open/close as calendar dates in its own - // bare-UTC wall clock. Comparing the two as instants silently drops the - // meetings on the last day of class for any facility west of UTC, so both - // sides are reduced to a plain date first. - dayDate := calendarDate(day) - if !course.startDt.IsZero() && dayDate.Before(calendarDate(course.startDt)) { - continue - } - if course.endDt != nil && dayDate.After(calendarDate(*course.endDt)) { - continue - } - for _, meeting := range course.weeklySchedule[day.Weekday()] { - midnight := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) - events = append(events, canvasScheduleEvent{ - ID: uint(len(events) + 1), - Title: course.name, - StartAt: midnight.Add(time.Duration(meeting.startMin) * time.Minute), - EndAt: midnight.Add(time.Duration(meeting.endMin) * time.Minute), - Timezone: loc.String(), - }) - } - } - return events -} - -// calendarDate strips a timestamp down to the Y/M/D it reads as in its own zone, -// so dates that arrived in different zones compare as dates rather than instants. -func calendarDate(t time.Time) time.Time { - year, month, day := t.Date() - return time.Date(year, month, day, 0, 0, 0, 0, time.UTC) -} - -// facilityLocation returns a facility's timezone, falling back to UTC when the -// facility is unknown or its timezone will not load. -func (srv *server) facilityLocation(facilityID uint) *time.Location { - if facilityID == 0 { - return time.UTC - } - facility, err := srv.store.GetFacilityByID(int(facilityID)) - if err != nil { - return time.UTC - } - return locationOrUTC(facility.Timezone) -} - -// locationOrUTC loads an IANA timezone name, falling back to UTC when it will -// not load. Callers that already hold a facility use this rather than -// facilityLocation, which reads the facility back from the database. -func locationOrUTC(timezone string) *time.Location { - loc, err := time.LoadLocation(timezone) - if err != nil { - return time.UTC - } - return loc -} - -func (srv *server) handleGetCanvasClassSchedule(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class_id") - } - if uint(classID) < CanvasClassIDOffset { - return newInvalidIdServiceError(fmt.Errorf("class %d is not a canvas class", classID), "class_id") - } - claims := claimsFrom(r) - if !claims.HasFeature(features.ProviderPlatforms) { - return newInvalidIdServiceError(fmt.Errorf("provider access disabled for class %d", classID), "class_id") - } - facilityIDFromID, providerID, rawCourseID := resolveCanvasClassParts(uint(classID)) - provider, err := srv.providerByID(r.Context(), providerID) - if err != nil { - return newDatabaseServiceError(err) - } - - now := time.Now() - var start, end time.Time - if sdStr := r.URL.Query().Get("start_date"); sdStr != "" { - if t, err := time.Parse("2006-01-02", sdStr); err == nil { - start = t - } - } - if edStr := r.URL.Query().Get("end_date"); edStr != "" { - if t, err := time.Parse("2006-01-02", edStr); err == nil { - end = t - } - } - if start.IsZero() || end.IsZero() { - month := int(now.Month()) - year := now.Year() - if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 { - month = m - } - if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 2000 { - year = y - } - start = time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) - end = start.AddDate(0, 1, -1) - } - - // Providers that report a weekly meeting pattern instead of dated calendar - // events have their pattern expanded across the requested range. Canvas has - // real events, so it is read straight from the calendar endpoint. - if !isCanvasProvider(provider) { - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return newInvalidIdServiceError(err, "class_id") - } - course, _, err := reader.GetCourse(r.Context(), rawCourseID) - if err != nil { - return newInternalServerServiceError(err, "failed to fetch course from "+providerDisplayName(provider)) - } - args := getQueryContext(r) - facilityID := facilityIDFromID - if facilityID == 0 { - facilityID = args.FacilityID - } - events := expandWeeklySchedule(course, start, end, srv.facilityLocation(facilityID)) - args.Total = int64(len(events)) - return writePaginatedResponse(w, http.StatusOK, events, args.IntoMeta()) - } - - fetchURL := fmt.Sprintf( - "%s/api/v1/calendar_events?context_codes[]=course_%d&start_date=%s&end_date=%s&per_page=100", - provider.BaseURL, rawCourseID, - start.Format("2006-01-02"), end.Format("2006-01-02"), - ) - - raw, err := srv.fetchAllCanvasPages(r.Context(), provider, fetchURL, 0) - if err != nil { - return newInternalServerServiceError(err, "failed to fetch canvas events") - } - - courseTimezone := srv.fetchCanvasCourseTimezone(provider, rawCourseID) - - events := make([]canvasScheduleEvent, 0, len(raw)) - for _, ev := range raw { - startAtStr, _ := ev["start_at"].(string) - endAtStr, _ := ev["end_at"].(string) - if startAtStr == "" || endAtStr == "" { - continue - } - startAt, err := time.Parse(time.RFC3339, startAtStr) - if err != nil { - continue - } - endAt, err := time.Parse(time.RFC3339, endAtStr) - if err != nil { - continue - } - title, _ := ev["title"].(string) - isCancelled := ev["workflow_state"] == "deleted" - var id uint - if idFloat, ok := ev["id"].(float64); ok { - id = encodeCanvasClassID(providerID, uint(idFloat)) - } - events = append(events, canvasScheduleEvent{ - ID: id, - Title: title, - StartAt: startAt, - EndAt: endAt, - IsCancelled: isCancelled, - Timezone: courseTimezone, - }) - } - - args := getQueryContext(r) - args.Total = int64(len(events)) - return writePaginatedResponse(w, http.StatusOK, events, args.IntoMeta()) -} - -// canvasEnrollmentRow mirrors EnrollmentDetails JSON shape so the -// frontend's ClassEnrollment type maps correctly. -type canvasEnrollmentRow struct { - ProgramClassEnrollment - NameFull string `json:"name_full"` - DocID string `json:"doc_id"` - ClassName string `json:"class_name"` - StartDt string `json:"start_dt"` - CompletionDt string `json:"completion_dt"` -} - -func (srv *server) handleGetCanvasClassEnrollments(w http.ResponseWriter, r *http.Request, log sLog, classID uint) error { - facilityIDFromID, providerID, rawCourseID := resolveCanvasClassParts(classID) - provider, err := srv.providerByID(r.Context(), providerID) - if err != nil { - return newDatabaseServiceError(err) - } - if !isLiveProgramProvider(provider) { - return newInvalidIdServiceError(fmt.Errorf("provider %d is not a live program provider", providerID), "class ID") - } - claims := claimsFrom(r) - if !claims.HasFeature(features.ProviderPlatforms) { - return newInvalidIdServiceError(fmt.Errorf("provider access disabled for provider %d", providerID), "class ID") - } - - // Canvas reports when each student joined, in the same call that lists them. - // Other live providers only report the roster, so EnrolledAt stays nil rather - // than being invented. - var ( - canvasUserIDs []string - enrollmentDates map[string]*time.Time - ) - if isCanvasProvider(provider) { - canvasUserIDs, enrollmentDates, err = srv.fetchCanvasCourseEnrollmentData(provider, rawCourseID) - } else { - canvasUserIDs, err = srv.providerCourseEnrolleeIDs(provider, rawCourseID) - } - if err != nil { - return newInternalServerServiceError(err, "failed to fetch enrollments from "+providerDisplayName(provider)) - } - - facilityID := facilityIDFromID - if facilityID == 0 { - facilityID = getQueryContext(r).FacilityID - } - userMap, err := srv.fetchCanvasMappedUsers(providerID, canvasUserIDs, facilityID) - if err != nil { - return newDatabaseServiceError(err) - } - - rows := make([]canvasEnrollmentRow, 0, len(canvasUserIDs)) - for i, externalID := range canvasUserIDs { - info, matched := userMap[externalID] - if !matched { - continue - } - rows = append(rows, canvasEnrollmentRow{ - ProgramClassEnrollment: ProgramClassEnrollment{ - DatabaseFields: DatabaseFields{ID: uint(i + 1)}, - CohortID: classID, - UserID: info.UserID, - EnrollmentStatus: Enrolled, - EnrolledAt: enrollmentDates[externalID], - }, - NameFull: info.NameFirst + " " + info.NameLast, - DocID: info.DocID, - ClassName: "", - }) - } - - args := getQueryContext(r) - args.Total = int64(len(rows)) - return writePaginatedResponse(w, http.StatusOK, rows, args.IntoMeta()) -} - -// handleGetCanvasAtRiskStudents calls the Canvas student_summaries analytics -// endpoint and returns AttendanceFlag entries for students showing low engagement. -// A student is flagged if they trigger at least two of: -// - page_views == 0 (never logged in) -// - participations == 0 with assignments present (no submissions) -// - missing / total > 0.30 (>30 % of assignments not submitted) -func (srv *server) handleGetCanvasAtRiskStudents(w http.ResponseWriter, r *http.Request, classID uint) error { - facilityIDFromID, providerID, rawCourseID := resolveCanvasClassParts(classID) - provider, err := srv.providerByID(r.Context(), providerID) - if err != nil { - return newDatabaseServiceError(err) - } - if !isLiveProgramProvider(provider) { - return newInvalidIdServiceError(fmt.Errorf("provider %d is not a live program provider", providerID), "class ID") - } - claims := claimsFrom(r) - if !claims.HasFeature(features.ProviderPlatforms) { - return newInvalidIdServiceError(fmt.Errorf("provider access disabled for provider %d", providerID), "class ID") - } - - // Course analytics are Canvas-only; other live providers report no engagement - // data, so there is nothing to flag rather than an error to report. - if !isCanvasProvider(provider) { - args := getQueryContext(r) - args.Total = 0 - return writePaginatedResponse(w, http.StatusOK, []AttendanceFlag{}, args.IntoMeta()) - } - - summaryURL := fmt.Sprintf("%s/api/v1/courses/%d/analytics/student_summaries", provider.BaseURL, rawCourseID) - summaries, err := srv.fetchAllCanvasPages(r.Context(), provider, summaryURL, 0) - if err != nil { - // Analytics may not be enabled on this Canvas instance — return empty rather than error. - log.WithError(err).Warnf("canvas student_summaries unavailable for course %d, returning empty at-risk list", rawCourseID) - args := getQueryContext(r) - args.Total = 0 - return writePaginatedResponse(w, http.StatusOK, []AttendanceFlag{}, args.IntoMeta()) - } - - canvasUserIDs := make([]string, 0, len(summaries)) - for _, s := range summaries { - if id, ok := s["id"].(float64); ok { - canvasUserIDs = append(canvasUserIDs, fmt.Sprintf("%d", int(id))) - } - } - - facilityID := facilityIDFromID - if facilityID == 0 { - facilityID = getQueryContext(r).FacilityID - } - userMap, err := srv.fetchCanvasMappedUsers(providerID, canvasUserIDs, facilityID) - if err != nil { - return newDatabaseServiceError(err) - } - - flags := make([]AttendanceFlag, 0) - for _, s := range summaries { - canvasIDStr := "" - if id, ok := s["id"].(float64); ok { - canvasIDStr = fmt.Sprintf("%d", int(id)) - } - info, matched := userMap[canvasIDStr] - if !matched { - continue - } - - pageViews, participations := 0, 0 - if v, ok := s["page_views"].(float64); ok { - pageViews = int(v) - } - if v, ok := s["participations"].(float64); ok { - participations = int(v) - } - - total, missing, onTime, late := 0, 0, 0, 0 - if td, ok := s["tardiness_breakdown"].(map[string]interface{}); ok { - if v, ok := td["total"].(float64); ok { - total = int(v) - } - if v, ok := td["missing"].(float64); ok { - missing = int(v) - } - if v, ok := td["on_time"].(float64); ok { - onTime = int(v) - } - if v, ok := td["late"].(float64); ok { - late = int(v) - } - } - - riskScore := 0 - if pageViews == 0 { - riskScore++ - } - if total > 0 && participations == 0 { - riskScore++ - } - if total > 0 && float64(missing)/float64(total) > 0.30 { - riskScore++ - } - if riskScore < 2 { - continue - } - - submitted := onTime + late - attendanceRate := 0 - if total > 0 { - attendanceRate = submitted * 100 / total - } - flagType := MultipleAbsences - if pageViews == 0 || participations == 0 { - flagType = NoAttendance - } - - flags = append(flags, AttendanceFlag{ - NameFirst: info.NameFirst, - NameLast: info.NameLast, - DocID: info.DocID, - FlagType: flagType, - UserID: info.UserID, - TotalSessions: total, - AttendedSessions: submitted, - MissedSessions: missing, - AttendanceRate: attendanceRate, - ConsecutiveAbsences: 0, - }) - } - - args := getQueryContext(r) - args.Total = int64(len(flags)) - return writePaginatedResponse(w, http.StatusOK, flags, args.IntoMeta()) -} diff --git a/backend/src/modules/programs/canvas_programs_test.go b/backend/src/modules/programs/canvas_programs_test.go deleted file mode 100644 index 372434746..000000000 --- a/backend/src/modules/programs/canvas_programs_test.go +++ /dev/null @@ -1,785 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/UnlockedLabs/unlocked-contracts/providerplatforms" -) - -// These tests pin down the behavior of the live provider path before it is -// generalized to support a second provider. The synthetic ID arithmetic is the -// part that guarantees two providers never collide, so it is covered first. - -func TestEncodeDecodeCanvasClassID(t *testing.T) { - tests := []struct { - name string - providerID uint - rawCourseID uint - }{ - {"first provider, first course", 1, 1}, - {"provider zero", 0, 42}, - {"max course id", 1, 999_999}, - {"max provider id", 999, 999_999}, - {"typical canvas course", 2, 105}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - encoded := encodeCanvasClassID(tt.providerID, tt.rawCourseID) - assert.GreaterOrEqual(t, encoded, CanvasClassIDOffset, - "encoded id must clear the offset so the dispatch guards fire") - provider, course := decodeCanvasClassID(encoded) - assert.Equal(t, tt.providerID, provider) - assert.Equal(t, tt.rawCourseID, course) - }) - } -} - -// Two providers must never produce the same class ID for different courses. -// This is what lets a second provider reuse the existing offsets untouched. -func TestCanvasClassIDsDoNotCollideAcrossProviders(t *testing.T) { - seen := map[uint]string{} - for providerID := uint(1); providerID <= 3; providerID++ { - for _, rawCourseID := range []uint{1, 2, 105, 999_999} { - id := encodeCanvasClassID(providerID, rawCourseID) - key := fmt.Sprintf("provider=%d course=%d", providerID, rawCourseID) - if prev, dup := seen[id]; dup { - t.Fatalf("class id %d produced by both %q and %q", id, prev, key) - } - seen[id] = key - } - } -} - -func TestEncodeDecodeFacilityCanvasClassID(t *testing.T) { - tests := []struct { - name string - facilityID uint - providerID uint - rawCourseID uint - }{ - {"typical", 1, 1, 105}, - {"zero facility", 0, 2, 7}, - {"max values", 999, 999, 999_999}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - encoded := encodeFacilityCanvasClassID(tt.facilityID, tt.providerID, tt.rawCourseID) - assert.GreaterOrEqual(t, encoded, CanvasFacilityClassIDOffset) - assert.GreaterOrEqual(t, encoded, CanvasClassIDOffset, - "facility-scoped ids must still satisfy the plain class guards") - facility, provider, course := decodeFacilityCanvasClassID(encoded) - assert.Equal(t, tt.facilityID, facility) - assert.Equal(t, tt.providerID, provider) - assert.Equal(t, tt.rawCourseID, course) - }) - } -} - -func TestResolveCanvasClassParts(t *testing.T) { - t.Run("facility-scoped id returns embedded facility", func(t *testing.T) { - encoded := encodeFacilityCanvasClassID(3, 2, 105) - facility, provider, course := resolveCanvasClassParts(encoded) - assert.Equal(t, uint(3), facility) - assert.Equal(t, uint(2), provider) - assert.Equal(t, uint(105), course) - }) - - t.Run("old-style id reports facility 0 so caller falls back to claims", func(t *testing.T) { - encoded := encodeCanvasClassID(2, 105) - facility, provider, course := resolveCanvasClassParts(encoded) - assert.Equal(t, uint(0), facility) - assert.Equal(t, uint(2), provider) - assert.Equal(t, uint(105), course) - }) -} - -func TestIsCanvasProvider(t *testing.T) { - tests := []struct { - providerType providerplatforms.ProviderType - want bool - }{ - {providerplatforms.CanvasOSS, true}, - {providerplatforms.CanvasCloud, true}, - {providerplatforms.Kolibri, false}, - {providerplatforms.Brightspace, false}, - {providerplatforms.ProviderType("essential_ed"), false}, - } - for _, tt := range tests { - t.Run(string(tt.providerType), func(t *testing.T) { - got := isCanvasProvider(&providerplatforms.Provider{Type: tt.providerType}) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestParseCanvasCourse(t *testing.T) { - now := time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) - - t.Run("full course", func(t *testing.T) { - entry, ok := parseCanvasCourse(map[string]interface{}{ - "id": float64(105), - "name": "Introduction to Botany", - "course_code": "BOT101", - "start_at": "2026-01-05T00:00:00Z", - "end_at": "2026-12-05T00:00:00Z", - }, 2, now) - - assert.True(t, ok) - assert.Equal(t, uint(105), entry.rawID) - assert.Equal(t, encodeCanvasClassID(2, 105), entry.encodedID) - assert.Equal(t, "Introduction to Botany", entry.name) - assert.Equal(t, "BOT101", entry.description) - assert.Equal(t, 2026, entry.startDt.Year()) - assert.NotNil(t, entry.endDt) - assert.Equal(t, Active, entry.status, "end date in the future stays Active") - }) - - t.Run("past end date marks the course Completed", func(t *testing.T) { - entry, ok := parseCanvasCourse(map[string]interface{}{ - "id": float64(7), - "name": "Old Course", - "end_at": "2020-01-01T00:00:00Z", - }, 1, now) - - assert.True(t, ok) - assert.Equal(t, Completed, entry.status) - }) - - t.Run("missing end date stays Active with nil endDt", func(t *testing.T) { - entry, ok := parseCanvasCourse(map[string]interface{}{ - "id": float64(7), - "name": "Ongoing", - }, 1, now) - - assert.True(t, ok) - assert.Nil(t, entry.endDt) - assert.Equal(t, Active, entry.status) - }) - - t.Run("non-numeric id is rejected rather than panicking", func(t *testing.T) { - _, ok := parseCanvasCourse(map[string]interface{}{"id": "105"}, 1, now) - assert.False(t, ok) - }) - - t.Run("missing id is rejected", func(t *testing.T) { - _, ok := parseCanvasCourse(map[string]interface{}{"name": "No ID"}, 1, now) - assert.False(t, ok) - }) - - t.Run("unparseable dates do not fail the course", func(t *testing.T) { - entry, ok := parseCanvasCourse(map[string]interface{}{ - "id": float64(9), - "start_at": "not-a-date", - "end_at": "also-not-a-date", - }, 1, now) - - assert.True(t, ok) - assert.True(t, entry.startDt.IsZero()) - assert.Nil(t, entry.endDt) - }) -} - -func TestBuildCourseTimezoneMap(t *testing.T) { - got := buildCourseTimezoneMap([]map[string]interface{}{ - {"id": float64(1), "time_zone": "America/Chicago"}, - {"id": float64(2), "time_zone": ""}, - {"id": float64(3)}, - {"name": "no id"}, - {"id": float64(4), "time_zone": "America/New_York"}, - }) - - assert.Equal(t, map[uint]string{ - 1: "America/Chicago", - 4: "America/New_York", - }, got, "courses without a usable time_zone are omitted entirely") -} - -// fetchAllCanvasPages is the function that gains a pagination seam when a second -// provider is added, so its current contract is pinned here: bearer auth, Link -// header paging, accumulation across pages, and a hard error on non-200. -func TestFetchAllCanvasPages(t *testing.T) { - t.Run("sends bearer auth and accepts json", func(t *testing.T) { - var gotAuth, gotAccept string - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - gotAccept = r.Header.Get("Accept") - _ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": float64(1)}}) - })) - defer ts.Close() - - srv := &server{Client: ts.Client()} - provider := &providerplatforms.Provider{AccessKey: "secret-token"} - - out, err := srv.fetchAllCanvasPages(context.Background(), provider, ts.URL, 0) - assert.NoError(t, err) - assert.Len(t, out, 1) - assert.Equal(t, "Bearer secret-token", gotAuth) - assert.Equal(t, "application/json", gotAccept) - }) - - t.Run("follows Link rel=next across pages", func(t *testing.T) { - var ts *httptest.Server - calls := 0 - ts = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - if calls == 1 { - w.Header().Set("Link", `<`+ts.URL+`/page2>; rel="next"`) - _ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": float64(1)}}) - return - } - _ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": float64(2)}}) - })) - defer ts.Close() - - srv := &server{Client: ts.Client()} - out, err := srv.fetchAllCanvasPages(context.Background(), &providerplatforms.Provider{}, ts.URL, 0) - - assert.NoError(t, err) - assert.Equal(t, 2, calls) - assert.Len(t, out, 2, "results from every page are accumulated") - }) - - t.Run("non-200 is an error, not an empty result", func(t *testing.T) { - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer ts.Close() - - srv := &server{Client: ts.Client()} - out, err := srv.fetchAllCanvasPages(context.Background(), &providerplatforms.Provider{}, ts.URL, 0) - - assert.Error(t, err) - assert.Nil(t, out) - }) - - t.Run("malformed json is an error", func(t *testing.T) { - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"not":"an array"}`)) - })) - defer ts.Close() - - srv := &server{Client: ts.Client()} - _, err := srv.fetchAllCanvasPages(context.Background(), &providerplatforms.Provider{}, ts.URL, 0) - - assert.Error(t, err) - }) - - t.Run("a cancelled context stops the fetch", func(t *testing.T) { - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode([]map[string]interface{}{}) - })) - defer ts.Close() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - srv := &server{Client: ts.Client()} - _, err := srv.fetchAllCanvasPages(ctx, &providerplatforms.Provider{}, ts.URL, 0) - - assert.Error(t, err) - }) -} - -// --- the live-provider seam --------------------------------------------------- - -func TestIsLiveProgramProvider(t *testing.T) { - tests := []struct { - providerType providerplatforms.ProviderType - want bool - }{ - {providerplatforms.CanvasOSS, true}, - {providerplatforms.CanvasCloud, true}, - {providerplatforms.Kolibri, false}, - {providerplatforms.Brightspace, false}, - } - for _, tt := range tests { - t.Run(string(tt.providerType), func(t *testing.T) { - got := isLiveProgramProvider(&providerplatforms.Provider{Type: tt.providerType}) - assert.Equal(t, tt.want, got) - }) - } -} - -// A provider's access key rides on every request to its base URL, so a plaintext -// URL would put it on the wire. The handlers reject one on the way in; the -// transports refuse to send to one regardless of how the URL got there. -func TestRequireHTTPS(t *testing.T) { - tests := []struct { - name string - rawURL string - wantErr bool - }{ - {"an https url is accepted", "https://api.essentialed.com/v20181129", false}, - {"surrounding whitespace is tolerated", " https://canvas.example.com ", false}, - {"plaintext http is refused", "http://canvas.example.com", true}, - {"a scheme-less url is refused", "canvas.example.com", true}, - {"an empty url is refused", "", true}, - {"another scheme is refused", "ftp://canvas.example.com", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := requireHTTPS(tt.rawURL) - if tt.wantErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - }) - } -} - -// The guard has to hold on every page, not just the first: a Canvas Link header -// names the next page, and that URL comes from the provider. -func TestFetchAllCanvasPagesRefusesPlaintextNextPage(t *testing.T) { - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Link", `; rel="next"`) - _, _ = w.Write([]byte(`[{"id":1}]`)) - })) - defer ts.Close() - srv := &server{Client: ts.Client()} - provider := &providerplatforms.Provider{BaseURL: ts.URL, AccessKey: "secret-token", Type: providerplatforms.CanvasOSS} - - out, err := srv.fetchAllCanvasPages(context.Background(), provider, ts.URL+"/api/v1/courses", 0) - - assert.Error(t, err) - assert.Nil(t, out) -} - -func TestTransportFor(t *testing.T) { - srv := &server{} - - t.Run("canvas types get the canvas transport", func(t *testing.T) { - for _, pt := range []providerplatforms.ProviderType{providerplatforms.CanvasOSS, providerplatforms.CanvasCloud} { - transport, err := srv.transportFor(&providerplatforms.Provider{Type: pt}) - assert.NoError(t, err) - assert.IsType(t, canvasTransport{}, transport) - } - }) - - t.Run("non-live types are rejected", func(t *testing.T) { - for _, pt := range []providerplatforms.ProviderType{providerplatforms.Kolibri, providerplatforms.Brightspace} { - transport, err := srv.transportFor(&providerplatforms.Provider{Type: pt}) - assert.Error(t, err) - assert.Nil(t, transport) - } - }) -} - -func TestCanvasURLBuilders(t *testing.T) { - provider := &providerplatforms.Provider{BaseURL: "http://canvas", AccountID: "1"} - - assert.Equal(t, - "http://canvas/api/v1/accounts/1/courses?per_page=100", - canvasAccountCoursesURL(provider)) - - assert.Equal(t, - "http://canvas/api/v1/courses/105", - canvasCourseURL(provider, 105)) -} - -func TestCanvasTransportFetchOne(t *testing.T) { - t.Run("returns the decoded record and authorizes the request", func(t *testing.T) { - var gotAuth string - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "id": float64(105), "time_zone": "America/Chicago", - }) - })) - defer ts.Close() - - transport := canvasTransport{srv: &server{Client: ts.Client()}} - provider := &providerplatforms.Provider{AccessKey: "tok"} - - record, err := transport.fetchOne(context.Background(), provider, ts.URL) - assert.NoError(t, err) - assert.Equal(t, "Bearer tok", gotAuth) - assert.Equal(t, "America/Chicago", record["time_zone"]) - }) - - t.Run("non-200 is an error", func(t *testing.T) { - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer ts.Close() - - transport := canvasTransport{srv: &server{Client: ts.Client()}} - record, err := transport.fetchOne(context.Background(), &providerplatforms.Provider{}, ts.URL) - assert.Error(t, err) - assert.Nil(t, record) - }) - - t.Run("malformed json is an error", func(t *testing.T) { - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`[not an object]`)) - })) - defer ts.Close() - - transport := canvasTransport{srv: &server{Client: ts.Client()}} - _, err := transport.fetchOne(context.Background(), &providerplatforms.Provider{}, ts.URL) - assert.Error(t, err) - }) -} - -func TestCanvasProviderListCourses(t *testing.T) { - future := time.Now().AddDate(1, 0, 0).Format("2006-01-02T15:04:05Z") - past := time.Now().AddDate(-1, 0, 0).Format("2006-01-02T15:04:05Z") - - newReader := func(t *testing.T, payload []map[string]interface{}) (LiveProgramProvider, func()) { - t.Helper() - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(payload) - })) - srv := &server{Client: ts.Client()} - provider := &providerplatforms.Provider{BaseURL: ts.URL, AccountID: "1", Type: providerplatforms.CanvasOSS} - reader, err := srv.newLiveProgramProvider(provider) - assert.NoError(t, err) - return reader, ts.Close - } - - t.Run("parses courses and collects timezones", func(t *testing.T) { - reader, done := newReader(t, []map[string]interface{}{ - {"id": float64(1), "name": "Active", "end_at": future, "time_zone": "America/Chicago"}, - {"id": float64(2), "name": "Done", "end_at": past}, - }) - defer done() - - listing, err := reader.ListCourses(context.Background()) - assert.NoError(t, err) - assert.Len(t, listing.Courses, 2) - assert.Equal(t, 2, listing.Total) - assert.Equal(t, Active, listing.Courses[0].status) - assert.Equal(t, Completed, listing.Courses[1].status) - assert.Equal(t, map[uint]string{1: "America/Chicago"}, listing.Timezones) - }) - - // Total counts what the provider returned; Courses counts what we could parse. - // The overview's class count is reported against Total, so this gap matters. - t.Run("unparseable records count toward Total but are not returned", func(t *testing.T) { - reader, done := newReader(t, []map[string]interface{}{ - {"id": float64(1), "name": "Good"}, - {"id": "not-a-number", "name": "Bad"}, - }) - defer done() - - listing, err := reader.ListCourses(context.Background()) - assert.NoError(t, err) - assert.Len(t, listing.Courses, 1) - assert.Equal(t, 2, listing.Total) - }) - - t.Run("empty listing is not an error", func(t *testing.T) { - reader, done := newReader(t, []map[string]interface{}{}) - defer done() - - listing, err := reader.ListCourses(context.Background()) - assert.NoError(t, err) - assert.Empty(t, listing.Courses) - assert.Equal(t, 0, listing.Total) - }) - - t.Run("encoded ids are scoped to the provider", func(t *testing.T) { - reader, done := newReader(t, []map[string]interface{}{{"id": float64(105), "name": "C"}}) - defer done() - - listing, err := reader.ListCourses(context.Background()) - assert.NoError(t, err) - // The test provider has a zero ID, so this pins the encoding, not the value. - assert.Equal(t, encodeCanvasClassID(0, 105), listing.Courses[0].encodedID) - }) - - t.Run("a transport error propagates", func(t *testing.T) { - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer ts.Close() - - srv := &server{Client: ts.Client()} - reader, err := srv.newLiveProgramProvider( - &providerplatforms.Provider{BaseURL: ts.URL, AccountID: "1", Type: providerplatforms.CanvasOSS}) - assert.NoError(t, err) - - _, err = reader.ListCourses(context.Background()) - assert.Error(t, err) - }) -} - -func TestNewLiveProgramProviderRejectsNonLiveTypes(t *testing.T) { - srv := &server{} - for _, pt := range []providerplatforms.ProviderType{providerplatforms.Kolibri, providerplatforms.Brightspace} { - reader, err := srv.newLiveProgramProvider(&providerplatforms.Provider{Type: pt}) - assert.Error(t, err) - assert.Nil(t, reader) - } -} - -// filterCanvasPrograms and its helpers close the gap from the #1232 review: -// Canvas rows have no backing `programs` row, so GetProgramsOverviewTable's -// SQL WHERE clauses never see them, and before this they were appended to -// every response unfiltered and counted in meta.total regardless of what the -// admin was actually filtering for. - -func canvasRow(id uint, name string, totalActiveClasses, totalActiveEnrollments *int64, types string, loading bool) ProgramsOverviewTable { - return ProgramsOverviewTable{ - ProgramID: id, - ProgramName: name, - Description: "Courses pulled live from a connection: " + name, - TotalActiveClasses: totalActiveClasses, - TotalActiveEnrollments: totalActiveEnrollments, - Types: types, - Status: true, - Source: "canvas", - Loading: loading, - } -} - -func int64Ptr(v int64) *int64 { return &v } - -func TestCanvasStatusMatches(t *testing.T) { - assert.True(t, canvasStatusMatches("Available")) - assert.True(t, canvasStatusMatches("Inactive|Available")) - assert.False(t, canvasStatusMatches("Inactive")) - assert.False(t, canvasStatusMatches("Archived")) - assert.False(t, canvasStatusMatches("Inactive|Archived")) -} - -func TestCanvasTypesMatch(t *testing.T) { - assert.True(t, canvasTypesMatch("College", "College")) - assert.True(t, canvasTypesMatch("College", "Employment|College")) - assert.True(t, canvasTypesMatch("College", "college")) // SQL side is case-insensitive (~*) - assert.False(t, canvasTypesMatch("College", "Employment")) -} - -func TestCanvasNumericMatches(t *testing.T) { - tests := []struct { - name string - value *int64 - filter string - expected bool - }{ - {"matches >=", int64Ptr(5), ">=5", true}, - {"fails >=", int64Ptr(3), ">=5", false}, - {"matches exact", int64Ptr(5), "=5", true}, - {"malformed value does not exclude the row", int64Ptr(5), "garbage", true}, - {"nil metric (still loading) cannot satisfy a real filter", nil, ">=0", false}, - {"nil metric with malformed filter still passes (filter is a no-op)", nil, "garbage", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, canvasNumericMatches(tt.value, tt.filter)) - }) - } -} - -func TestFilterCanvasPrograms(t *testing.T) { - rows := []ProgramsOverviewTable{ - canvasRow(1, "Canvas - DOC", int64Ptr(4), int64Ptr(10), "College", false), - canvasRow(2, "Canvas - Facility B", int64Ptr(1), int64Ptr(2), "College", false), - canvasRow(3, "Canvas - Loading", nil, nil, "", true), - } - - t.Run("no filters keeps every row", func(t *testing.T) { - out := filterCanvasPrograms(rows, &QueryContext{}, map[string]string{}) - assert.Len(t, out, 3) - }) - - t.Run("search matches provider name", func(t *testing.T) { - out := filterCanvasPrograms(rows, &QueryContext{Search: "doc"}, map[string]string{}) - assert.Len(t, out, 1) - assert.Equal(t, uint(1), out[0].ProgramID) - }) - - t.Run("status filter for Available keeps all live canvas rows", func(t *testing.T) { - out := filterCanvasPrograms(rows, &QueryContext{}, map[string]string{"programs.is_active": "Available"}) - assert.Len(t, out, 3) - }) - - t.Run("status filter for Inactive/Archived excludes every canvas row", func(t *testing.T) { - out := filterCanvasPrograms(rows, &QueryContext{}, map[string]string{"programs.is_active": "Inactive"}) - assert.Empty(t, out) - }) - - t.Run("numeric filter on total_active_classes excludes rows below threshold and the still-loading row", func(t *testing.T) { - out := filterCanvasPrograms(rows, &QueryContext{}, map[string]string{"mr.total_active_classes": ">=2"}) - assert.Len(t, out, 1) - assert.Equal(t, uint(1), out[0].ProgramID) - }) - - t.Run("a filter dimension Canvas rows cannot satisfy excludes every row", func(t *testing.T) { - for _, col := range []string{"pct.credit_types", "programs.funding_type", "capacity_utilization", - "completion_rate", "attendance_rate", "mr.total_active_facilities", "facility_id"} { - out := filterCanvasPrograms(rows, &QueryContext{}, map[string]string{col: "anything"}) - assert.Empty(t, out, "filter %q should exclude all canvas rows", col) - } - }) - - t.Run("program type filter matches the hardcoded College type", func(t *testing.T) { - out := filterCanvasPrograms(rows, &QueryContext{}, map[string]string{"pt.program_types": "College"}) - assert.Len(t, out, 2) // the still-loading row has no type yet and is excluded - }) -} - -// Canvas rows are appended after the database rows and counted in meta.total, -// so the page window arithmetic has to hold for every page or total/last_page -// disagree between pages and page 1 overflows per_page. -func TestCanvasPageSlice(t *testing.T) { - canvasRows := func(n int) []ProgramsOverviewTable { - rows := make([]ProgramsOverviewTable, n) - for i := range rows { - rows[i] = ProgramsOverviewTable{ProgramID: CanvasProgramIDOffset + uint(i)} - } - return rows - } - tests := []struct { - name string - canvasCount int - dbTotal int64 - page, perPage int - all bool - wantIDs []uint - }{ - {"db total is an exact multiple of per_page, canvas spills to page 2", 1, 10, 1, 10, false, nil}, - {"spilled canvas row lands on page 2", 1, 10, 2, 10, false, []uint{CanvasProgramIDOffset}}, - {"partial last page has room for the canvas row", 1, 5, 1, 10, false, []uint{CanvasProgramIDOffset}}, - {"canvas rows split across the page boundary", 3, 12, 2, 10, false, []uint{CanvasProgramIDOffset, CanvasProgramIDOffset + 1, CanvasProgramIDOffset + 2}}, - {"page before any canvas position", 3, 25, 1, 10, false, nil}, - {"page straddling the first canvas row", 3, 25, 3, 10, false, []uint{CanvasProgramIDOffset, CanvasProgramIDOffset + 1, CanvasProgramIDOffset + 2}}, - {"page past every canvas row", 2, 5, 3, 5, false, nil}, - {"no canvas rows", 0, 10, 1, 10, false, nil}, - {"all skips pagination", 2, 10, 1, 10, true, []uint{CanvasProgramIDOffset, CanvasProgramIDOffset + 1}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - args := QueryContext{Page: tt.page, PerPage: tt.perPage, All: tt.all, Total: tt.dbTotal} - got := canvasPageSlice(canvasRows(tt.canvasCount), tt.dbTotal, &args) - gotIDs := make([]uint, 0, len(got)) - for _, p := range got { - gotIDs = append(gotIDs, p.ProgramID) - } - assert.Equal(t, tt.wantIDs, nilIfEmpty(gotIDs)) - if !tt.all { - assert.LessOrEqual(t, int64(len(got)), max(int64(tt.perPage)-(tt.dbTotal-int64(args.CalcOffset())), 0), - "page must not return more rows than per_page once the database rows are counted") - } - }) - } -} - -func nilIfEmpty(ids []uint) []uint { - if len(ids) == 0 { - return nil - } - return ids -} - -// Canvas rows must be counted in meta.total on every page, otherwise total and -// last_page change depending on which page the client asks for. -func TestCanvasTotalIsPageIndependent(t *testing.T) { - const dbTotal, canvasCount, perPage = 10, 1, 10 - var totals, lastPages []int64 - for page := 1; page <= 2; page++ { - args := QueryContext{Page: page, PerPage: perPage, Total: dbTotal} - args.Total += canvasCount - meta := args.IntoMeta() - totals = append(totals, meta.Total) - lastPages = append(lastPages, int64(meta.LastPage)) - } - assert.Equal(t, []int64{11, 11}, totals) - assert.Equal(t, []int64{2, 2}, lastPages) -} - -func TestNextPageURL(t *testing.T) { - tests := []struct { - name string - header string - want string - }{ - { - name: "standard next link", - header: `; rel="next"`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - { - name: "next among multiple relations", - header: `; rel="prev", ; rel="next", ; rel="last"`, - want: "https://canvas.example.com/api/v1/courses?page=3", - }, - { - name: "no next relation", - header: `; rel="last"`, - want: "", - }, - { - name: "empty header", - header: "", - want: "", - }, - { - name: "case-insensitive rel name", - header: `; REL="next"`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - { - name: "case-insensitive rel value", - header: `; rel="Next"`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - { - name: "extra whitespace around semicolon and equals", - header: ` ; rel = "next"`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - { - name: "rel before additional params", - header: `; rel="next"; title="Next Page"`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - { - name: "rel after other params (attribute order variation)", - header: `; title="Next Page"; rel="next"`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - { - name: "URL containing a comma", - header: `; rel="next"`, - want: "https://canvas.example.com/api/v1/courses?foo=a,b&page=2", - }, - { - name: "URL containing a semicolon (percent-encoded)", - header: `; rel="next"`, - want: "https://canvas.example.com/api/v1/courses?filter=a%3Bb", - }, - { - name: "multiple space-separated rel tokens including next", - header: `; rel="alternate next"`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - { - name: "unquoted rel value", - header: `; rel=next`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - { - name: "first entry has no next, second does", - header: `; rel="current", ; rel="next"`, - want: "https://canvas.example.com/api/v1/courses?page=2", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := NextPageURL(tt.header) - if got != tt.want { - t.Errorf("NextPageURL(%q)\n got %q\n want %q", tt.header, got, tt.want) - } - }) - } -} diff --git a/backend/src/modules/programs/change_log_entry.go b/backend/src/modules/programs/change_log_entry.go deleted file mode 100644 index baca214fc..000000000 --- a/backend/src/modules/programs/change_log_entry.go +++ /dev/null @@ -1,70 +0,0 @@ -package programs - -import ( - "fmt" - "reflect" - "slices" - "time" -) - -type ChangeLogEntry struct { - ID uint `gorm:"primaryKey" json:"id"` - NameTable string `json:"table_name" gorm:"column:table_name;size:255"` - ParentRefID uint `json:"parent_ref_id"` - FieldName string `json:"field_name"` - OldValue *string `json:"old_value"` - NewValue *string `json:"new_value"` - CreatedAt time.Time `json:"created_at"` - UserID uint `json:"user_id"` - Username string `json:"username" gorm:"->"` -} - -func (ChangeLogEntry) TableName() string { - return "change_log_entries" -} - -func NewChangeLogEntry(tableName, fieldName string, oldValue, newValue *string, parentRefID, userID uint) *ChangeLogEntry { - return &ChangeLogEntry{ - NameTable: tableName, - ParentRefID: parentRefID, - FieldName: fieldName, - OldValue: oldValue, - NewValue: newValue, - CreatedAt: time.Now(), - UserID: userID, - } -} - -func derefToString(v reflect.Value) string { - - for v.Kind() == reflect.Pointer { - if v.IsNil() { - return "" - } - v = v.Elem() - } - return fmt.Sprint(v.Interface()) -} - -func GenerateChangeLogEntries(oldRecord, updRecord interface{}, tableName string, parentID uint, userID uint, ignoreFieldNames []string) []ChangeLogEntry { - var entries []ChangeLogEntry - oldVal := reflect.ValueOf(oldRecord).Elem() - newVal := reflect.ValueOf(updRecord).Elem() - kind := oldVal.Type() - - for i := 0; i < oldVal.NumField(); i++ { - field := kind.Field(i) - name := field.Tag.Get("json") - if !oldVal.Field(i).CanInterface() || slices.Contains(ignoreFieldNames, name) || name == "-" || name == "" { - continue - } - - oldValue := derefToString(oldVal.Field(i)) - newValue := derefToString(newVal.Field(i)) - - if oldValue != newValue { - entries = append(entries, *NewChangeLogEntry(tableName, name, StringPtr(oldValue), StringPtr(newValue), parentID, userID)) - } - } - return entries -} diff --git a/backend/src/modules/programs/class_enrollments.go b/backend/src/modules/programs/class_enrollments.go deleted file mode 100644 index f889339c4..000000000 --- a/backend/src/modules/programs/class_enrollments.go +++ /dev/null @@ -1,231 +0,0 @@ -package programs - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "time" -) - -func (srv *server) handleGetUserProgramCompletions(w http.ResponseWriter, r *http.Request, log sLog) error { - userId, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "User ID") - } - args := getQueryContext(r) - classID := args.MaybeID("class_id") - enrollemnt, err := srv.store.GetClassCompletionsForUser(&args, userId, classID) - if err != nil { - return newDatabaseServiceError(err) - } - return writePaginatedResponse(w, http.StatusOK, enrollemnt, args.IntoMeta()) -} - -func (srv *server) handleGetEnrollmentsForProgram(w http.ResponseWriter, r *http.Request, log sLog) error { - classId, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "Class ID") - } - if uint(classId) >= CanvasClassIDOffset { - return srv.handleGetCanvasClassEnrollments(w, r, log, uint(classId)) - } - status := r.URL.Query().Get("status") - if status == "all" { - status = "" - } - log.add("status", status) - args := getQueryContext(r) - enrollments, err := srv.store.GetProgramClassEnrollmentsForProgram(&args, classId, status) - if err != nil { - return newDatabaseServiceError(err) - } - return writePaginatedResponse(w, http.StatusOK, enrollments, args.IntoMeta()) -} - -func (srv *server) handleCheckEnrollmentConflicts(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - body := struct { - UserIDs []int `json:"user_ids"` - }{} - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - return newJSONReqBodyServiceError(err) - } - if len(body.UserIDs) == 0 { - return writeJsonResponse(w, http.StatusOK, map[string]any{"conflicts": []any{}}) - } - conflicts, err := srv.store.CheckSchedulingConflicts(classID, body.UserIDs) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, map[string]any{"conflicts": conflicts}) -} - -func (srv *server) handleEnrollUsersInClass(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - log.add("class_id", classID) - class, err := srv.store.GetCohortByID(classID) - if err != nil { - return newDatabaseServiceError(err) - } - if class.CannotUpdateClass() { - return newBadRequestServiceError(err, "cannot perform action on class that is completed cancelled or archived") - } - enrollment := struct { - UserIDs []int `json:"user_ids"` - Confirm bool `json:"confirm"` // Check for explicit confirmation - }{} - err = json.NewDecoder(r.Body).Decode(&enrollment) - if err != nil { - return newJSONReqBodyServiceError(err) - } - - deactivatedUsers, err := srv.store.DeactivatedUsersPresent(enrollment.UserIDs) - if err != nil { - return newDatabaseServiceError(err) - } - if deactivatedUsers { - return newBadRequestServiceError(errors.New("cannot enroll deactivated user"), "deactivated user") - } - - if !enrollment.Confirm { - conflicts, err := srv.store.CheckSchedulingConflicts(classID, enrollment.UserIDs) - if err != nil { - return newDatabaseServiceError(err) - } - if len(conflicts) > 0 { - return writeJsonResponse(w, http.StatusConflict, map[string]any{ - "message": "Scheduling conflicts detected", - "conflicts": conflicts, - }) - } - } - - skipped, err := srv.WithUserContext(r).CreateProgramClassEnrollments(classID, enrollment.UserIDs) - if err != nil { - return newDatabaseServiceError(err) - } - response := "users enrolled" - if skipped > 0 { - response = fmt.Sprintf("%d users were enrolled, %d were not added because capacity is full.", len(enrollment.UserIDs)-skipped, skipped) - } - return writeJsonResponse(w, http.StatusCreated, response) -} - -func (srv *server) handleDeleteProgramClassEnrollments(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "program class enrollment ID") - } - log.add("class_enrollment_id", id) - err = srv.WithUserContext(r).DeleteProgramClassEnrollments(id) - if err != nil { - return newDatabaseServiceError(err) - } - log.info("class enrollment deleted") - return writeJsonResponse(w, http.StatusNoContent, "Class enrollment deleted successfully") -} - -func (srv *server) handleUpdateProgramClassEnrollments(w http.ResponseWriter, r *http.Request, log sLog) error { - classId, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class enrollment ID") - } - enrollment := struct { - EnrollmentStatus ProgramEnrollmentStatus `json:"enrollment_status"` - UserIDs []int `json:"user_ids"` - ChangeReason *string `json:"change_reason,omitempty"` - }{} - if err := json.NewDecoder(r.Body).Decode(&enrollment); err != nil { - return newJSONReqBodyServiceError(err) - } - if enrollment.EnrollmentStatus == "" { - return newInvalidIdServiceError(errors.New("enrollment status is required"), "enrollment status") - } - class, err := srv.store.GetCohortByID(classId) - if err != nil { - return newDatabaseServiceError(err) - } - if class.CannotUpdateClassWithEnrollment(enrollment.EnrollmentStatus) { - return newBadRequestServiceError(err, "cannot perform action due to invalid class or enrollment status") - } - switch enrollment.EnrollmentStatus { - case "Completed": - // The completion records the admin's email, from the users table as - // core's claims had it. - var admin *User - admin, err = srv.store.GetUserByID(claimsFrom(r).UserID) - if err != nil { - return newDatabaseServiceError(err) - } - err = srv.WithUserContext(r).GraduateEnrollments(admin.Email, enrollment.UserIDs, classId) - default: - err = srv.WithUserContext(r).UpdateProgramClassEnrollments(classId, enrollment.UserIDs, enrollment.EnrollmentStatus, enrollment.ChangeReason) - } - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, "updated") -} - -func (srv *server) handleUpdateEnrollmentDate(w http.ResponseWriter, r *http.Request, log sLog) error { - classId, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - enrollmentId, err := strconv.Atoi(r.PathValue("enrollment_id")) - if err != nil { - return newInvalidIdServiceError(err, "enrollment ID") - } - enrollmentUpdate := struct { - EnrolledDate string `json:"enrolled_date"` - }{} - if err := json.NewDecoder(r.Body).Decode(&enrollmentUpdate); err != nil { - return newJSONReqBodyServiceError(err) - } - if enrollmentUpdate.EnrolledDate == "" { - return newBadRequestServiceError(errors.New("enrolled date is required"), "enrolled date") - } - enrolledDate, err := time.Parse(time.RFC3339, enrollmentUpdate.EnrolledDate) - if err != nil { - return newBadRequestServiceError(err, "invalid date format") - } - class, err := srv.store.GetCohortByID(classId) - if err != nil { - return newDatabaseServiceError(err) - } - if class.CannotUpdateClass() { - return newBadRequestServiceError(errors.New("cannot perform action on class that is completed cancelled or archived"), "invalid class status") - } - err = srv.WithUserContext(r).UpdateProgramClassEnrollmentDate(enrollmentId, enrolledDate) - if err != nil { - return newDatabaseServiceError(err) - } - log.add("enrollment_id", enrollmentId) - log.info("enrollment date updated") - return writeJsonResponse(w, http.StatusOK, "updated") -} - -func (srv *server) handleGetProgramClassEnrollmentsAttendance(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("enrollment_id")) - if err != nil { - return newInvalidIdServiceError(err, "class enrollment ID") - } - log.add("class_enrollment_id", id) - page, perPage := getPaginationInfo(r) - total, attendance, err := srv.store.GetProgramClassEnrollmentsAttendance(page, perPage, id) - if err != nil { - return newDatabaseServiceError(err) - } - log.add("total", total) - log.info("class enrollment attendance fetched") - paginationData := newPaginationInfo(page, perPage, total) - return writePaginatedResponse(w, http.StatusOK, attendance, paginationData) -} diff --git a/backend/src/modules/programs/class_enrollments_store.go b/backend/src/modules/programs/class_enrollments_store.go deleted file mode 100644 index f6b8f03bb..000000000 --- a/backend/src/modules/programs/class_enrollments_store.go +++ /dev/null @@ -1,610 +0,0 @@ -package programs - -import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/identity" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// completionPreloads are the associations newClassCompletion needs to build a complete -// certificate snapshot. Both completion paths must use these -- a missing preload here -// produces a certificate with blank denormalized fields rather than an error. -func completionPreloads(tx *gorm.DB) *gorm.DB { - return tx.Preload("User.Facility"). - Preload("Cohort.Program.ProgramCreditTypes"). - Preload("Cohort.FacilityProg"). - Preload("Cohort.Class.CreditTypes") -} - -func newClassCompletion(e ProgramClassEnrollment, adminEmail string) ClassCompletion { - completion := ClassCompletion{ - UserID: e.UserID, - AdminEmail: adminEmail, - EnrolledOnDt: e.CreatedAt, - } - - // Both are pointers on purpose: the FKs are ON DELETE SET NULL so a deleted cohort - // cannot erase an earned certificate. - if e.CohortID != 0 { - cohortID := e.CohortID - completion.CohortID = &cohortID - } - if e.ClassID != 0 { - classID := e.ClassID - completion.ClassID = &classID - } - if e.User != nil && e.User.Facility != nil { - completion.FacilityName = e.User.Facility.Name - } - if e.Cohort == nil { - return completion - } - - // The class's name, not the cohort's -- the cohort has none. Preferring the preloaded - // association and falling back to the joined alias means this works whether the caller - // used completionPreloads or a query that only selected `pc.name AS class_name`. - if e.Cohort.Class != nil { - completion.ClassName = e.Cohort.Class.Name - } else { - completion.ClassName = e.Cohort.ClassName - } - completion.CohortStartDt = e.Cohort.StartDt - completion.ProgramOwner = e.Cohort.GetProgramOwnerOrEmpty() - if e.Cohort.ProgramID != 0 { - programID := e.Cohort.ProgramID - completion.ProgramID = &programID - } - if e.Cohort.Program != nil { - completion.ProgramName = e.Cohort.Program.Name - } - completion.CreditType = resolveCreditTypeString(e.Cohort) - - return completion -} - -// resolveCreditTypeString applies the empty-means-inherit rule: a class's own credit -// types win, and a class with none inherits its program's. Formatted as the existing -// comma-joined snapshot string so reports and exports are unchanged. -func resolveCreditTypeString(cohort *ProgramClassCohort) string { - var programTypes []CreditType - if cohort.Program != nil { - for _, ct := range cohort.Program.ProgramCreditTypes { - programTypes = append(programTypes, ct.CreditType) - } - } - - resolved := programTypes - if cohort.Class != nil { - resolved = cohort.Class.CreditTypesOrInherited(programTypes) - } - - seen := make(map[CreditType]struct{}, len(resolved)) - unique := make([]string, 0, len(resolved)) - for _, ct := range resolved { - if _, dup := seen[ct]; dup { - continue - } - seen[ct] = struct{}{} - unique = append(unique, string(ct)) - } - return strings.Join(unique, ",") -} - -func (db *Store) GetClassCompletionsForUser(args *QueryContext, userId int, classId *int) ([]ClassCompletion, error) { - content := make([]ClassCompletion, 0) - tx := db.WithContext(args.Ctx).Model(&ClassCompletion{}).Preload("User").Where("user_id = ?", userId) - if classId != nil { - tx = tx.Where("cohort_id = ?", *classId) - } - if err := tx.Count(&args.Total).Error; err != nil { - return nil, newNotFoundDBError(err, "program class enrollments") - } - if err := tx.Find(&content).Error; err != nil { - return nil, newNotFoundDBError(err, "program class enrollments") - } - return content, nil -} - -func (db *Store) GetProgramClassEnrollmentsByID(id int) (*ProgramClassEnrollment, error) { - content := &ProgramClassEnrollment{} - if err := db.First(content, "id = ?", id).Error; err != nil { - return nil, newNotFoundDBError(err, "class enrollments") - } - return content, nil -} - -func (db *Store) GetEnrollmentsForClass(page, perPage, classId int) (int64, []ProgramClassEnrollment, error) { - content := []ProgramClassEnrollment{} - var total int64 - tx := db.Model(&ProgramClassEnrollment{}).Where("cohort_id = ?", classId) - if err := tx.Count(&total).Error; err != nil { - return 0, nil, newNotFoundDBError(err, "program class enrollments") - } - if err := tx.Limit(perPage).Offset(calcOffset(page, perPage)).Find(&content).Error; err != nil { - return 0, nil, newNotFoundDBError(err, "program class enrollments") - } - return total, content, nil -} - -func (db *Store) GetProgramClassEnrollmentsForFacility(page, perPage int, facilityID uint) (int64, []ProgramClassEnrollment, error) { - content := []ProgramClassEnrollment{} - var total int64 //count - tx := db.Model(&ProgramClassEnrollment{}). - Joins("JOIN program_class_cohorts ps ON program_class_enrollments.cohort_id = ps.id and ps.deleted_at IS NULL"). - Where("ps.facility_id = ?", facilityID) - - _ = tx.Count(&total) - - if err := tx.Limit(perPage). - Offset(calcOffset(page, perPage)). - Find(&content).Error; err != nil { - return 0, nil, newNotFoundDBError(err, "program class enrollments") - } - return total, content, nil -} - -// Returns the number of enrollments that were skipped, and possible error -func (db *Store) CreateProgramClassEnrollments(classID int, userIds []int) (int, error) { - var result struct { - Available int - Status ClassStatus - } - err := db. - Table("program_class_cohorts"). - Select("program_class_cohorts.status, program_class_cohorts.capacity - COALESCE(COUNT(pce.id), 0) AS available"). - Joins(`LEFT JOIN program_class_enrollments pce ON pce.cohort_id = program_class_cohorts.id - and pce.enrollment_status = 'Enrolled'`). - Where("program_class_cohorts.id = ?", classID). - Group("program_class_cohorts.status, program_class_cohorts.id, program_class_cohorts.capacity"). - Scan(&result).Error - if err != nil { - return 0, newNotFoundDBError(err, "program class cohort") - } - - if result.Available <= 0 { - return len(userIds), newNotFoundDBError(fmt.Errorf("class is full"), "program class cohort") - } - - enrollments := make([]ProgramClassEnrollment, 0, min(len(userIds), result.Available)) - for _, uid := range userIds { - if result.Available <= 0 { - break - } - enrollments = append(enrollments, ProgramClassEnrollment{ - CohortID: uint(classID), - UserID: uint(uid), - EnrollmentStatus: Enrolled, - }) - result.Available-- - } - - skipped := len(userIds) - len(enrollments) - if err := db.Create(&enrollments).Error; err != nil { - return 0, newCreateDBError(err, "class enrollment") - } - - return skipped, nil -} - -func (db *Store) DeleteProgramClassEnrollments(id int) error { - if err := db.Model(&ProgramClassEnrollment{}).Delete(&ProgramClassEnrollment{}, "id = ?", id).Error; err != nil { - return newDeleteDBError(err, "class enrollment") - } - return nil -} - -// GraduateEnrollments marks residents in one COHORT as completed and issues each of them -// the parent CLASS's certificate. cohortId is a cohort id, not a class id. -func (db *Store) GraduateEnrollments(adminEmail string, userIds []int, cohortId int) error { - tx := db.Begin() - - var enrollments []ProgramClassEnrollment - err := completionPreloads(tx.Model(&ProgramClassEnrollment{})). - Where("cohort_id = ? AND user_id IN (?)", cohortId, userIds). - Find(&enrollments).Error - if err != nil { - tx.Rollback() - return newNotFoundDBError(err, "class enrollment") - } - if len(enrollments) == 0 { - tx.Rollback() - return newNotFoundDBError(fmt.Errorf("no enrollments found"), "class enrollment") - } - - completions := make([]ClassCompletion, 0, len(enrollments)) - for _, enrollment := range enrollments { - completions = append(completions, newClassCompletion(enrollment, adminEmail)) - } - - // DO NOTHING, not an error: the certificate is per (user, class), so a resident who - // already earned it in a sibling cohort keeps the original earn date rather than - // getting a duplicate or a 500. Enforced by class_completions_user_class_uniq. - if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&completions).Error; err != nil { - tx.Rollback() - return newCreateDBError(err, "enrollment completion") - } - - if err = tx.Model(&ProgramClassEnrollment{}). - Where("user_id IN (?) AND cohort_id = ?", userIds, cohortId). - Set("cohort_id", cohortId). - Update("enrollment_status", EnrollmentCompleted).Error; err != nil { - tx.Rollback() - return newUpdateDBError(err, "enrollment status") - } - - // commit the transaction - return tx.Commit().Error -} - -func (db *Store) UpdateProgramClassEnrollments(classId int, userIds []int, status ProgramEnrollmentStatus, changeReason *string) error { - updates := map[string]any{ - "enrollment_status": status, - } - if changeReason != nil { - updates["change_reason"] = NilIfBlank(*changeReason) - } - if ctx := db.Statement.Context; ctx != nil { - if c, ok := identity.FromContext(ctx); ok { - updates["update_user_id"] = c.UserID - } - } - if err := db.Model(&ProgramClassEnrollment{}). - Where("cohort_id = ? AND user_id IN (?)", classId, userIds). - Set("cohort_id", classId). - Updates(updates).Error; err != nil { - return newUpdateDBError(err, "class enrollment status") - } - return nil -} - -func (db *Store) UpdateProgramClassEnrollmentDate(enrollmentId int, enrolledDate time.Time) error { - update := map[string]any{"enrolled_at": enrolledDate} - if ctx := db.Statement.Context; ctx != nil { - if c, ok := identity.FromContext(ctx); ok { - update["update_user_id"] = c.UserID - } - } - if err := db.Model(&ProgramClassEnrollment{}). - Where("id = ?", enrollmentId). - Updates(update).Error; err != nil { - return newUpdateDBError(err, "enrollment date") - } - return nil -} - -// changeLogValue renders a JSON-decoded update value for the audit log. The map comes from -// json.Decode into map[string]any, so numbers arrive as float64 and an explicit null as nil -// -- a bare value.(string) assertion PANICS on either and aborts the whole update. nil maps -// to a nil pointer, which the change log reads as "cleared". -func changeLogValue(value any) *string { - switch v := value.(type) { - case nil: - return nil - case string: - return StringPtr(v) - case float64: - return StringPtr(strconv.FormatFloat(v, 'f', -1, 64)) - case bool: - return StringPtr(strconv.FormatBool(v)) - default: - return StringPtr(fmt.Sprintf("%v", v)) - } -} - -// UpdateProgramCohorts updates COHORT rows (status, dates, capacity) and cascades the -// consequences: terminating enrollments, issuing certificates, and writing the audit log. -// cohortIDs are cohort ids -- status is a cohort-level concept, a class has none. -func (db *Store) UpdateProgramCohorts(cohortIDs []int, cohortMap map[string]any) error { - tx := db.Begin() - if tx.Error != nil { - return newUpdateDBError(tx.Error, "begin transaction") - } - defer tx.Rollback() - - var ( - toBeCompletedEnrollments []ProgramClassEnrollment - adminEmail string - ) - - if status, ok := cohortMap["status"]; ok && - (status == string(Cancelled) || status == string(Completed)) { - completionTime := time.Now().UTC() - if err := db.UpdateClassEventRRuleUntilDate(tx, cohortIDs, completionTime); err != nil { - return newUpdateDBError(err, "updating class event rrule until date") - } - for _, cohortID := range cohortIDs { - var enrollmentStatus ProgramEnrollmentStatus - switch status { - case string(Cancelled): - enrollmentStatus = EnrollmentCancelled - case string(Completed): - enrollmentStatus = EnrollmentCompleted - } - - if err := tx. - Model(&ProgramClassEnrollment{}). - Where("cohort_id = ? AND enrollment_status = ?", cohortID, Enrolled). - Set("cohort_id", cohortID). - Update("enrollment_status", enrollmentStatus). - Error; err != nil { - return newUpdateDBError(err, "class enrollment statuses") - } - } - - // Fetch enrollments that will be used create program completions AFTER the update - if status == string(Completed) { - if err := completionPreloads(tx.Model(&ProgramClassEnrollment{})). - Where("cohort_id IN (?) AND enrollment_status = ?", cohortIDs, EnrollmentCompleted). - Find(&toBeCompletedEnrollments).Error; err != nil { - return newNotFoundDBError(err, "fetching updated enrollments") - } - } - } - - if err := tx. - Model(&ProgramClassCohort{}). - Where("id IN ?", cohortIDs). - Updates(cohortMap). - Error; err != nil { - return newUpdateDBError(err, "program classes") - } - - if status, ok := cohortMap["status"]; ok && status == string(Active) { - activation := map[string]any{"enrolled_at": time.Now().UTC()} - if uid, ok := cohortMap["update_user_id"]; ok { - activation["update_user_id"] = uid - } - if err := tx. - Model(&ProgramClassEnrollment{}). - Where("cohort_id IN ? AND enrollment_status = ? AND enrolled_at IS NULL", cohortIDs, Enrolled). - Updates(activation). - Error; err != nil { - return newUpdateDBError(err, "backfilling enrolled_at on activation") - } - } - - if len(toBeCompletedEnrollments) > 0 { - rawUID, ok := cohortMap["update_user_id"] - if !ok { - return newUpdateDBError(fmt.Errorf("missing update_user_id in cohortMap"), "program classes") - } - - updateUserID, ok := rawUID.(uint) - if !ok { - return newUpdateDBError(fmt.Errorf("update_user_id must be of type uint"), "program classes") - } - - var admin User - if err := tx.First(&admin, "id = ?", updateUserID).Error; err != nil { - return newNotFoundDBError(err, "admin user") - } - adminEmail = admin.Email - - completions := make([]ClassCompletion, 0, len(toBeCompletedEnrollments)) - for _, enrollment := range toBeCompletedEnrollments { - completions = append(completions, newClassCompletion(enrollment, adminEmail)) - } - - // see the note in GraduateEnrollments -- one certificate per (user, class) - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&completions).Error; err != nil { - return newCreateDBError(err, "enrollment completion") - } - - } - // cohortMap is decoded straight from the request JSON, so update_user_id is resolved - // once here rather than re-asserted inside the loop. - rawLogUID, ok := cohortMap["update_user_id"] - if !ok { - return newUpdateDBError(fmt.Errorf("missing update_user_id in cohortMap"), "program classes") - } - logUserID, ok := rawLogUID.(uint) - if !ok { - return newUpdateDBError(fmt.Errorf("update_user_id must be of type uint"), "program classes") - } - - var ( - allChanges []ChangeLogEntry - logEntry ChangeLogEntry - ) - for _, cohortID := range cohortIDs { - for fieldName, value := range cohortMap { - if fieldName == "update_user_id" { - continue - } - logEntry = *NewChangeLogEntry(TableNameCohort, fieldName, nil, changeLogValue(value), uint(cohortID), logUserID) - allChanges = append(allChanges, logEntry) - } - } - if len(allChanges) > 0 { - if err := tx.Create(&allChanges).Error; err != nil { - return newCreateDBError(err, "change_log_entries") - } - } - - if err := tx.Commit().Error; err != nil { - return newUpdateDBError(err, "unable to commit database transaction") - } - - return nil -} - -type EnrollmentDetails struct { - ProgramClassEnrollment - NameFull string `json:"name_full"` - DocID string `json:"doc_id"` - ClassName string `json:"class_name"` - StartDt string `json:"start_dt"` - CompletionDt string `json:"completion_dt"` -} - -func (db *Store) GetProgramClassEnrollmentsForProgram(args *QueryContext, classId int, status string) ([]EnrollmentDetails, error) { - content := make([]EnrollmentDetails, 0, args.PerPage) - search := args.SearchQuery() - tx := db.WithContext(args.Ctx).Table("program_class_enrollments pse").Select("pse.*, u.name_first || ' ' || u.name_last as name_full, u.doc_id, cl.name as class_name, c.start_dt, pc.created_at as completion_dt"). - Joins("JOIN program_class_cohorts c ON pse.cohort_id = c.id AND c.deleted_at IS NULL"). - Joins("JOIN program_classes cl ON cl.id = c.class_id"). - Joins("JOIN users u ON pse.user_id = u.id AND u.deleted_at IS NULL"). - Joins("LEFT JOIN class_completions pc ON pc.user_id = pse.user_id AND pc.cohort_id = ?", classId). - Where("pse.cohort_id = ?", classId) - if status != "" { - if status == "not_enrolled" { - tx = tx.Where("pse.enrollment_status != ?", "Enrolled") - } else { - tx = tx.Where("pse.enrollment_status ILIKE ?", status) - } - } - if search != "" { - tx = tx.Where("u.name_last ILIKE ? OR u.name_first ILIKE ? OR u.doc_id ILIKE ?", search, search, search) - } - - if err := tx.Count(&args.Total).Error; err != nil { - return nil, newNotFoundDBError(err, "program class enrollments") - } - if err := tx.Limit(args.PerPage). - Offset(args.CalcOffset()).Order(adjustUserOrderBy(args.OrderClause("pse.created_at desc"))). - Find(&content).Error; err != nil { - return nil, newNotFoundDBError(err, "program class enrollments") - } - return content, nil -} - -func (db *Store) GetProgramClassEnrollmentsAttendance(page, perPage, id int) (int64, []AttendanceRecordResponse, error) { - content := []AttendanceRecordResponse{} - var total int64 - tx := db.Table("program_class_event_attendance att"). - Select(`att.user_id, att.date, att.attendance_status, att.note, - COALESCE(CONCAT(admin.name_first, ' ', admin.name_last), '') AS marked_by`). - Joins("JOIN program_class_events evt ON att.event_id = evt.id AND evt.deleted_at IS NULL"). - Joins("JOIN program_class_cohorts ps ON evt.cohort_id = ps.id AND ps.deleted_at IS NULL"). - Joins("JOIN program_class_enrollments pse ON ps.id = pse.cohort_id AND pse.deleted_at IS NULL"). - Joins("LEFT JOIN users admin ON att.create_user_id = admin.id"). - Where("pse.id = ?", id). - Where("att.user_id = pse.user_id"). - Where("att.deleted_at IS NULL") - - if err := tx.Count(&total).Error; err != nil { - return 0, nil, newNotFoundDBError(err, "class event") - } - if err := tx.Order("att.date DESC").Limit(perPage). - Offset(calcOffset(page, perPage)). - Find(&content).Error; err != nil { - return 0, nil, newNotFoundDBError(err, "class event attendance") - } - return total, content, nil -} - -func (db *Store) CheckSchedulingConflicts(cohortID int, userIDs []int) ([]ConflictDetail, error) { - var conflicts []ConflictDetail - allEventsQuery := &QueryContext{All: true} - targetEvents, err := db.GetClassEvents(allEventsQuery, cohortID) - if err != nil { - return nil, err - } - - if len(targetEvents) == 0 { - return nil, nil - } - - var allEnrollments []ProgramClassEnrollment - if err := db.Model(&ProgramClassEnrollment{}). - Preload("Class"). - Where("user_id IN (?) AND enrollment_status = ?", userIDs, Enrolled). - Find(&allEnrollments).Error; err != nil { - return nil, err - } - - enrollmentsByUser := make(map[int][]ProgramClassEnrollment) - for _, enrollment := range allEnrollments { - uid := int(enrollment.UserID) - enrollmentsByUser[uid] = append(enrollmentsByUser[uid], enrollment) - } - - for _, userID := range userIDs { - existingEnrollments := enrollmentsByUser[userID] - if len(existingEnrollments) == 0 { - continue - } - - for _, enrollment := range existingEnrollments { - if int(enrollment.CohortID) == cohortID { - continue - } - - existingClassEvents, err := db.GetClassEvents(allEventsQuery, int(enrollment.CohortID)) - if err != nil { - return nil, err - } - - if hasOverlap, start, end, days := checkEventsOverlap(targetEvents, existingClassEvents); hasOverlap { - var user User - if err := db.First(&user, userID).Error; err != nil { - return nil, err - } - - conflicts = append(conflicts, ConflictDetail{ - UserID: uint(userID), - UserName: user.NameLast + ", " + user.NameFirst, - ConflictingClass: enrollment.Class.Name, - ConflictStart: start, - ConflictEnd: end, - ConflictDays: days, - }) - } - } - } - - return conflicts, nil -} - -func checkEventsOverlap(newEvents, existingEvents []ProgramClassEvent) (bool, time.Time, time.Time, []string) { - startWindow := time.Now().UTC() - endWindow := startWindow.AddDate(0, 6, 0) - - var newInstances []EventInstance - for _, evt := range newEvents { - insts := generateEventInstances(evt, startWindow, endWindow) - newInstances = append(newInstances, insts...) - } - - var existingInstances []EventInstance - for _, evt := range existingEvents { - insts := generateEventInstances(evt, startWindow, endWindow) - existingInstances = append(existingInstances, insts...) - } - - var firstStart, firstEnd time.Time - seenDays := make(map[time.Weekday]bool) - for _, newInst := range newInstances { - newStart := newInst.StartTime - newEnd := newStart.Add(newInst.Duration) - - for _, exInst := range existingInstances { - exStart := exInst.StartTime - exEnd := exStart.Add(exInst.Duration) - - if newStart.Before(exEnd) && newEnd.After(exStart) { - if firstStart.IsZero() { - firstStart, firstEnd = newStart, newEnd - } - seenDays[newStart.Weekday()] = true - } - } - } - - if len(seenDays) == 0 { - return false, time.Time{}, time.Time{}, nil - } - - days := make([]string, 0, len(seenDays)) - for wd := time.Sunday; wd <= time.Saturday; wd++ { - if seenDays[wd] { - days = append(days, wd.String()) - } - } - return true, firstStart, firstEnd, days -} diff --git a/backend/src/modules/programs/class_enrollments_test.go b/backend/src/modules/programs/class_enrollments_test.go deleted file mode 100644 index 8341147ae..000000000 --- a/backend/src/modules/programs/class_enrollments_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package programs - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strconv" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/identity" - "github.com/UnlockedLabs/unlocked-contracts/users" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TimePtr(t time.Time) *time.Time { - return &t -} - -func TestHandleEnrollUsersInClass_Validation(t *testing.T) { - srv := newTestServer(t) - - // Setup Common Data - facility := Facility{Name: "Test Facility"} - if err := srv.store.Create(&facility).Error; err != nil { - t.Fatal(err) - } - - program := Program{Name: "Test Program"} - if err := srv.store.Create(&program).Error; err != nil { - t.Fatal(err) - } - - activeUser := User{Username: "active_user", Email: "active_user@example.com", NameFirst: "Active", NameLast: "User", FacilityID: facility.ID} - if err := srv.store.Create(&activeUser).Error; err != nil { - t.Fatal(err) - } - - activeUser2 := User{Username: "active_user_2", Email: "active_user_2@example.com", NameFirst: "Active", NameLast: "User2", FacilityID: facility.ID} - if err := srv.store.Create(&activeUser2).Error; err != nil { - t.Fatal(err) - } - - deactivatedUser := User{Username: "deactivated_user", Email: "deactivated_user@example.com", NameFirst: "Deactivated", NameLast: "User", FacilityID: facility.ID, DeactivatedAt: TimePtr(time.Now())} - if err := srv.store.Create(&deactivatedUser).Error; err != nil { - t.Fatal(err) - } - - tests := []struct { - name string - classStatus ClassStatus - isArchived bool - targetUsers []User - capacity int64 - setupClass func(*ProgramClassCohort) // Optional extra setup - expectedStatus int - expectedMsg string - }{ - { - name: "Enroll in Active Class (Success)", - classStatus: Active, - targetUsers: []User{activeUser}, - capacity: 10, - expectedStatus: http.StatusCreated, - expectedMsg: "users enrolled", - }, - { - name: "Enroll in Scheduled Class (Success)", - classStatus: Scheduled, - targetUsers: []User{activeUser}, - capacity: 10, - expectedStatus: http.StatusCreated, - expectedMsg: "users enrolled", - }, - { - name: "Enroll in Completed Class (Failure)", - classStatus: Completed, - targetUsers: []User{activeUser}, - capacity: 10, - expectedStatus: http.StatusBadRequest, - expectedMsg: "cannot perform action on class that is completed cancelled or archived", - }, - { - name: "Enroll in Cancelled Class (Failure)", - classStatus: Cancelled, - targetUsers: []User{activeUser}, - capacity: 10, - expectedStatus: http.StatusBadRequest, - expectedMsg: "cannot perform action on class that is completed cancelled or archived", - }, - { - name: "Enroll in Archived Class (Failure)", - classStatus: Active, - isArchived: true, - targetUsers: []User{activeUser}, - capacity: 10, - expectedStatus: http.StatusBadRequest, - expectedMsg: "cannot perform action on class that is completed cancelled or archived", - }, - { - name: "Enroll Deactivated User (Failure)", - classStatus: Active, - targetUsers: []User{deactivatedUser}, - capacity: 10, - expectedStatus: http.StatusBadRequest, - expectedMsg: "deactivated user", - }, - { - name: "Enroll in Full Class (Partial/Skipped)", - classStatus: Active, - targetUsers: []User{activeUser, activeUser2}, - capacity: 1, // Capacity 1, trying to enroll 2 users - setupClass: func(c *ProgramClassCohort) { - }, - expectedStatus: http.StatusCreated, - expectedMsg: "1 users were enrolled, 1 were not added because capacity is full.", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - class := ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Status: tt.classStatus, - Description: "Description", - Capacity: tt.capacity, - StartDt: time.Now(), - } - if tt.isArchived { - class.ArchivedAt = TimePtr(time.Now()) - } - if err := srv.store.Create(&class).Error; err != nil { - t.Fatal(err) - } - - if tt.setupClass != nil { - tt.setupClass(&class) - } - - userIDs := make([]int, len(tt.targetUsers)) - for i, u := range tt.targetUsers { - userIDs[i] = int(u.ID) - } - - enrollmentReq := struct { - UserIDs []int `json:"user_ids"` - }{ - UserIDs: userIDs, - } - body, _ := json.Marshal(enrollmentReq) - - req := httptest.NewRequest(http.MethodPost, "/api/program-classes/"+strconv.Itoa(int(class.ID))+"/enrollments", bytes.NewReader(body)) - req.SetPathValue("cohort_id", strconv.Itoa(int(class.ID))) - - w := httptest.NewRecorder() - log := sLog{ctx: req.Context(), f: make(map[string]interface{})} - - err := srv.handleEnrollUsersInClass(w, req, log) - - if tt.expectedStatus >= 400 { - assert.Error(t, err) - svcErr, ok := err.(serviceError) - assert.True(t, ok) - assert.Equal(t, tt.expectedStatus, svcErr.Status) - assert.Contains(t, svcErr.Message, tt.expectedMsg) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedStatus, w.Code) - // For 201, the response body is a JSON object with a message - var resp resource[any] - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - assert.Contains(t, resp.Message, tt.expectedMsg) - } - }) - } -} - -// A completion records the email of the admin who graduated the resident, -// read from the users table as core's claims had it. -func TestGraduationRecordsAdminEmail(t *testing.T) { - srv := newTestServer(t) - facility := Facility{Name: "North", Timezone: "America/Chicago"} - require.NoError(t, srv.store.Create(&facility).Error) - admin := User{Username: "facadmin", Email: "facadmin@example.com", Role: users.FacilityAdmin, FacilityID: facility.ID} - require.NoError(t, srv.store.Create(&admin).Error) - resident := User{Username: "resident", Email: "resident@example.com", Role: users.Student, FacilityID: facility.ID} - require.NoError(t, srv.store.Create(&resident).Error) - program := Program{Name: "GED"} - require.NoError(t, srv.store.Create(&program).Error) - class := ProgramClassCohort{ProgramID: program.ID, FacilityID: facility.ID, Status: Active, Description: "Description", Capacity: 10, StartDt: time.Now()} - require.NoError(t, srv.store.Create(&class).Error) - require.NoError(t, srv.store.Create(&ProgramClassEnrollment{CohortID: class.ID, UserID: resident.ID, EnrollmentStatus: Enrolled}).Error) - - body, err := json.Marshal(map[string]any{"enrollment_status": "Completed", "user_ids": []uint{resident.ID}}) - require.NoError(t, err) - cohortID := strconv.Itoa(int(class.ID)) - req := httptest.NewRequest(http.MethodPatch, "/api/program-classes/"+cohortID+"/enrollments", bytes.NewReader(body)) - req.SetPathValue("cohort_id", cohortID) - req = req.WithContext(identity.NewContext(req.Context(), &identity.Claims{UserID: admin.ID, Role: admin.Role, FacilityID: facility.ID})) - w := httptest.NewRecorder() - require.NoError(t, srv.handleUpdateProgramClassEnrollments(w, req, sLog{ctx: req.Context(), f: map[string]any{}})) - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - - var completion ClassCompletion - require.NoError(t, srv.store.Where("user_id = ?", resident.ID).First(&completion).Error) - assert.Equal(t, "facadmin@example.com", completion.AdminEmail) -} diff --git a/backend/src/modules/programs/class_event.go b/backend/src/modules/programs/class_event.go deleted file mode 100644 index 783169232..000000000 --- a/backend/src/modules/programs/class_event.go +++ /dev/null @@ -1,381 +0,0 @@ -package programs - -import ( - "errors" - "fmt" - "strings" - "time" - - "github.com/teambition/rrule-go" - "gorm.io/gorm" -) - -type Attendance string - -const ( - Present Attendance = "present" - Partial Attendance = "partial" - Absent_Excused Attendance = "absent_excused" - Absent_Unexcused Attendance = "absent_unexcused" -) - -func (a Attendance) HumanReadable() string { - switch a { - case Present: - return "Present" - case Absent_Excused: - return "Absent Excused" - case Absent_Unexcused: - return "Absent Unexcused" - default: - return string(a) - } -} - -var rruleDayAbbrev = map[string]string{ - "MO": "M", "TU": "T", "WE": "W", "TH": "Th", "FR": "F", "SA": "Sa", "SU": "Su", -} - -func FormatScheduleFromRRule(rruleStr string) string { - if rruleStr == "" { - return "" - } - opts, err := rrule.StrToROption(rruleStr) - if err != nil { - return "" - } - days := make([]string, 0, len(opts.Byweekday)) - for _, wd := range opts.Byweekday { - full := strings.ToUpper(wd.String()[:2]) - if abbr, ok := rruleDayAbbrev[full]; ok { - days = append(days, abbr) - } - } - timeStr := "" - if !opts.Dtstart.IsZero() { - timeStr = opts.Dtstart.Format("3:04 PM") - } - if len(days) > 0 && timeStr != "" { - return strings.Join(days, "/") + " " + timeStr - } - if len(days) > 0 { - return strings.Join(days, "/") - } - return timeStr -} - -/** Events are a physical time/place where a 'class' is held in a facility **/ -type ProgramClassEvent struct { - DatabaseFields - CohortID uint `json:"cohort_id" gorm:"column:cohort_id;not null" validate:"required"` - Duration string `json:"duration" gorm:"not null" validate:"required"` - RecurrenceRule string `json:"recurrence_rule" gorm:"not null" validate:"required"` - RoomID *uint `json:"room_id" gorm:"not null"` - InstructorID *uint `json:"instructor_id" gorm:"not null" validate:"required"` - Reason *string `json:"reason"` - IsCancelled bool `json:"is_cancelled"` - - /* Foreign keys */ - Cohort *ProgramClassCohort `json:"cohort" gorm:"foreignKey:CohortID;references:ID"` - RoomRef *Room `json:"room_ref,omitempty" gorm:"foreignKey:RoomID;references:ID"` - Instructor *User `json:"instructor_ref,omitempty" gorm:"foreignKey:InstructorID;references:ID"` - Attendees []ProgramClassEventAttendance `json:"attendees" gorm:"foreignKey:EventID;references:ID"` - Overrides []ProgramClassEventOverride `json:"overrides" gorm:"foreignKey:EventID;references:ID"` -} - -type DateRange struct { - Start time.Time - End time.Time - Tzone *time.Location -} - -func (dt *DateRange) LoadLocation(location *time.Location) { - dt.Start = dt.Start.In(location) - dt.End = dt.End.In(location) -} - -func (secEvent *ProgramClassEvent) BeforeCreate(tx *gorm.DB) (err error) { - if err := secEvent.DatabaseFields.BeforeCreate(tx); err != nil { - return err - } - // For events we only want to stamp creator on insert; leave update_user_id nil until first update - secEvent.UpdateUserID = nil - - duration, parseErr := time.ParseDuration(secEvent.Duration) - if parseErr != nil { - err = parseErr - return - } - if duration <= 0 { - err = errors.New("duration cannot be negative") - return - } - _, err = rrule.StrToRRule(secEvent.RecurrenceRule) - if err != nil { - return err - } - return -} - -func (ProgramClassEvent) TableName() string { return "program_class_events" } - -func (e *ProgramClassEvent) GetRRule() (*rrule.RRule, error) { - return e.GetRRuleWithTimezone("") -} - -func (e *ProgramClassEvent) GetRRuleWithTimezone(facilityTimezone string) (*rrule.RRule, error) { - rruleStr := e.RecurrenceRule - if strings.Contains(rruleStr, "TZID=Local") && facilityTimezone != "" { - rruleStr = strings.Replace(rruleStr, "TZID=Local", "TZID="+facilityTimezone, 1) - } - rruleOptions, err := rrule.StrToROption(rruleStr) - if err != nil { - return nil, fmt.Errorf("failed to parse event recurrence rule: %w", err) - } - // NOTE: We no longer convert Dtstart to UTC here. - // For timezone-aware RRULEs (DTSTART;TZID=...), this preserves wall-clock time across DST. - // For legacy UTC-based RRULEs (DTSTART:...Z), the DTSTART is already in UTC. - if !rruleOptions.Until.IsZero() { - loc := rruleOptions.Until.Location() - endOfDay := time.Date( - rruleOptions.Until.Year(), - rruleOptions.Until.Month(), - rruleOptions.Until.Day(), - 23, 59, 59, int(time.Second-time.Nanosecond), loc) - rruleOptions.Until = endOfDay.In(time.UTC) - } - rule, err := rrule.NewRRule(*rruleOptions) - if err != nil { - return nil, fmt.Errorf("failed to create event recurrence rule: %w", err) - } - return rule, nil -} - -func (event *ProgramClassEvent) RRuleUntil() (time.Time, error) { - rrule, err := event.GetRRule() - if err != nil { - return time.Time{}, err - } - return rrule.GetUntil(), nil -} - -/** Overrides are used to cancel or reschedule events **/ -type ProgramClassEventOverride struct { - DatabaseFields - EventID uint `json:"event_id" gorm:"not null"` - Duration string `json:"duration" gorm:"not null"` - OverrideRrule string `json:"override_rrule" gorm:"not null"` - // NOT a column -- a read-only query-time alias for the parent COHORT. Queries must - // supply it explicitly as `cohort_id AS cohort_id`. Do not add a gorm column tag. - CohortID uint `json:"cohort_id" gorm:"->" ` - IsCancelled bool `json:"is_cancelled"` - RoomID *uint `json:"room_id"` - Reason *string `json:"reason"` - LinkedOverrideEventID *uint `json:"linked_override_event_id"` - InstructorID *uint `json:"instructor_id"` - - /* Foreign keys */ - Event *ProgramClassEvent `json:"event" gorm:"foreignKey:EventID;references:ID"` - RoomRef *Room `json:"room_ref,omitempty" gorm:"foreignKey:RoomID;references:ID"` - Instructor *User `json:"instructor_ref,omitempty" gorm:"foreignKey:InstructorID;references:ID"` -} - -func (ProgramClassEventOverride) TableName() string { return "program_class_event_overrides" } - -// format argument will take a string in the format of "2006-01-02", "1/02/2006", ect -func (pce *ProgramClassEventOverride) GetFormattedOverrideDate(format string) (*string, error) { - rRule, err := rrule.StrToRRule(pce.OverrideRrule) - if err != nil { - return nil, err - } - if len(rRule.All()) < 1 { - return nil, errors.New("cancelled override rule does not contain a date instance") - } - cancelledDate := rRule.All()[0] - cancelledDateStr := cancelledDate.Format(format) - return StringPtr(cancelledDateStr), nil -} - -func (pce *ProgramClassEventOverride) GetRescheduleSummary(timezone string) (*string, error) { - timezoneLoc, err := time.LoadLocation(timezone) - if err != nil { - return nil, err - } - rRule, err := rrule.StrToRRule(pce.OverrideRrule) - if err != nil { - return nil, err - } - startDate := rRule.All()[0].In(timezoneLoc) //reschedule's will always be count=1 so we can do this - duration, err := time.ParseDuration(pce.Duration) - if err != nil { - return nil, err - } - end := startDate.Add(duration) - - date := startDate.Format("1/02/2006") - startTime := startDate.Format("3:04PM") - endTime := end.Format("3:04PM") - - roomName := "TBD" - if pce.RoomRef != nil { - roomName = pce.RoomRef.Name - } - - rescheduleSummary := date + " " + startTime + " - " + endTime + " (" + roomName + ")" - return &rescheduleSummary, nil -} - -/** Attendance records for Events **/ -type ProgramClassEventAttendance struct { - DatabaseFields - EventID uint `json:"event_id" gorm:"not null;uniqueIndex:idx_event_user_date"` - UserID uint `json:"user_id" gorm:"not null; uniqueIndex:idx_event_user_date"` - Date string `json:"date" gorm:"not null; uniqueIndex:idx_event_user_date" validate:"required,datetime"` - AttendanceStatus Attendance `json:"attendance_status" gorm:"column:attendance_status"` - Note *string `json:"note" gorm:"column:note"` - ReasonCategory *string `json:"reason_category" gorm:"column:reason_category"` - CheckInAt *string `json:"check_in_at" gorm:"column:check_in_at"` - CheckOutAt *string `json:"check_out_at" gorm:"column:check_out_at"` - MinutesAttended *int `json:"minutes_attended" gorm:"column:minutes_attended"` - ScheduledMinutes *int `json:"scheduled_minutes" gorm:"column:scheduled_minutes"` - - /* Foreign Keys */ - Event *ProgramClassEvent `json:"event" gorm:"foreignKey:EventID;references:ID"` - User *User `json:"user" gorm:"foreignKey:UserID;references:ID"` -} - -func (ProgramClassEventAttendance) TableName() string { return "program_class_event_attendance" } - -func (p *ProgramClassEventAttendance) BeforeCreate(tx *gorm.DB) error { - if err := p.DatabaseFields.BeforeCreate(tx); err != nil { - return err - } - return nil -} - -type AttendanceRecordResponse struct { - UserID uint `json:"user_id"` - Date string `json:"date"` - AttendanceStatus string `json:"attendance_status"` - Note string `json:"note"` - MarkedBy string `json:"marked_by"` -} - -type ClassEventInstance struct { - EventID uint `json:"event_id"` - ClassTime string `json:"class_time"` // e.g. "12:00-14:00" - Date string `json:"date"` - AttendanceRecords []ProgramClassEventAttendance `json:"attendance_records"` - IsCancelled bool `json:"is_cancelled"` - IsRescheduled bool `json:"is_rescheduled"` - RescheduledToDate string `json:"rescheduled_to_date,omitempty"` - RescheduledFromDate string `json:"rescheduled_from_date,omitempty"` - OverrideID uint `json:"override_id,omitempty"` -} - -type EnrollmentAttendance struct { - EnrollmentID uint `json:"enrollment_id"` - CohortID uint `json:"cohort_id"` - EnrollmentStatus string `json:"enrollment_status"` - UserID uint `json:"user_id"` - NameFirst string `json:"name_first"` - NameLast string `json:"name_last"` - DocID string `json:"doc_id"` - AttendanceID *uint `json:"attendance_id"` - EventID *uint `json:"event_id"` - EventDate *string `json:"date"` - AttendanceStatus *string `json:"attendance_status"` - Note *string `json:"note"` - ReasonCategory *string `json:"reason_category"` - CheckInAt *string `json:"check_in_at"` - CheckOutAt *string `json:"check_out_at"` - MinutesAttended *int `json:"minutes_attended"` - ScheduledMinutes *int `json:"scheduled_minutes"` -} - -type AttendanceFlagType string - -const ( - NoAttendance AttendanceFlagType = "no_attendance" - MultipleAbsences AttendanceFlagType = "multiple_absences" -) - -type AttendanceFlag struct { - NameFirst string `json:"name_first"` - NameLast string `json:"name_last"` - DocID string `json:"doc_id"` - FlagType AttendanceFlagType `json:"flag_type"` - UserID uint `json:"user_id"` - TotalSessions int `json:"total_sessions"` - AttendedSessions int `json:"attended_sessions"` - MissedSessions int `json:"missed_sessions"` - AttendanceRate int `json:"attendance_rate"` - ConsecutiveAbsences int `json:"consecutive_absences"` -} - -type MissingAttendanceItem struct { - CohortID uint `json:"cohort_id"` - ClassName string `json:"class_name"` - FacilityName string `json:"facility_name,omitempty"` - EventID uint `json:"event_id"` - Date string `json:"date"` - StartTime string `json:"start_time"` -} - -type MissingAttendanceClass struct { - ID uint `json:"id"` - Name string `json:"name"` - FacilityName string `json:"facility_name"` - FacilityID uint `json:"facility_id"` -} - -type EventDates struct { - EventID uint `json:"event_id"` - Date string `json:"date"` - ClassTime string `json:"class_time"` // e.g. "12:00-14:00" -} - -// GetInstructorFromEvents returns the instructor ID and display name from the -// event with the highest ID, mirroring the frontend getInstructorName utility. -func GetInstructorFromEvents(events []ProgramClassEvent) (*uint, string) { - var maxEvent *ProgramClassEvent - for i := range events { - if maxEvent == nil || events[i].ID > maxEvent.ID { - maxEvent = &events[i] - } - } - if maxEvent == nil { - return nil, "" - } - name := "" - if maxEvent.Instructor != nil { - name = strings.TrimSpace(maxEvent.Instructor.NameFirst + " " + maxEvent.Instructor.NameLast) - } - return maxEvent.InstructorID, name -} - -type FacilityProgramClassEvent struct { - ProgramClassEvent - Room string `json:"room" gorm:"->"` // read-only, populated from joined rooms table - OriginalRoom string `json:"original_room,omitempty" gorm:"-"` - InstructorName string `json:"instructor_name"` - OriginalInstructorName string `json:"original_instructor_name,omitempty" gorm:"-"` - ProgramID uint `json:"program_id"` - ProgramName string `json:"program_name"` - ClassName string `json:"title"` - IsCancelled bool `json:"is_cancelled"` - IsOverride bool `json:"is_override"` - EnrolledUsers string `json:"enrolled_users"` - StartTime *time.Time `json:"start"` - EndTime *time.Time `json:"end"` - Frequency string `json:"frequency"` - ClassStatus ClassStatus `json:"class_status"` - OverrideID uint `json:"override_id"` - LinkedOverrideEvent *FacilityProgramClassEvent `json:"linked_override_event" gorm:"-"` - IsCanvasEvent bool `json:"is_canvas_event" gorm:"-"` - Source string `json:"source,omitempty" gorm:"-"` - CanvasTimezone string `json:"canvas_timezone,omitempty" gorm:"-"` - // Loading marks a placeholder row standing in for a live provider whose - // events are still being fetched. It carries no times and is not a real event. - Loading bool `json:"loading,omitempty" gorm:"-"` -} diff --git a/backend/src/modules/programs/class_events.go b/backend/src/modules/programs/class_events.go deleted file mode 100644 index d5af2ebaf..000000000 --- a/backend/src/modules/programs/class_events.go +++ /dev/null @@ -1,618 +0,0 @@ -package programs - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/features" - "github.com/UnlockedLabs/unlocked-contracts/users" -) - -func (srv *server) handleGetAdminCalendar(w http.ResponseWriter, r *http.Request, log sLog) error { - dtRng, err := getDateRange(r) - if err != nil { - return newInvalidQueryParamServiceError(err, "start_dt") - } - args := facilityScopedQueryContext(r) - - id := r.URL.Query().Get("cohort_id") - var classID int - if id != "" { - classID, err = strconv.Atoi(id) - if err != nil { - return newInvalidIdServiceError(err, "class_id") - } - } - - events, err := srv.store.GetFacilityCalendar(&args, dtRng, classID) - if err != nil { - return newDatabaseServiceError(err) - } - // Merge live provider calendar events when not filtered to a single class. - // These are read from the cache rather than fetched here, so the calendar - // answers with our own events immediately; while a refresh runs the response - // carries a placeholder per provider instead of blocking on it. - claims := claimsFrom(r) - if classID == 0 && claims.HasFeature(features.ProviderPlatforms) { - canvasEvents, loading := srv.getCanvasProviderEvents(dtRng, args.FacilityID) - events = append(events, canvasEvents...) - if loading { - events = append(events, srv.loadingEventPlaceholders()...) - } - } - return writeJsonResponse(w, http.StatusOK, events) -} - -func (srv *server) handleGetStudentCalendar(w http.ResponseWriter, r *http.Request, log sLog) error { - dtRng, err := getDateRange(r) - if err != nil { - return newInvalidQueryParamServiceError(err, "start_dt") - } - args := getQueryContext(r) - events, err := srv.store.GetFacilityCalendar(&args, dtRng, 0) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, events) -} - -func (srv *server) handleGetTodaysSchedule(w http.ResponseWriter, r *http.Request, log sLog) error { - args := getQueryContext(r) - facility := r.URL.Query().Get("facility") - var facilityId *uint - switch facility { - case "all": - facilityId = nil - default: - facilityId = &args.FacilityID - } - - service := NewClassesService(srv.store) - items, err := service.GetTodaysSchedule(&args, facilityId) - if err != nil { - return newDatabaseServiceError(err) - } - - args.Total = int64(len(items)) - if !args.All { - start := args.CalcOffset() - if start < len(items) { - end := start + args.PerPage - if end > len(items) { - end = len(items) - } - items = items[start:end] - } else { - items = []TodaysScheduleItem{} - } - } - - return writePaginatedResponse(w, http.StatusOK, items, args.IntoMeta()) -} - -func (srv *server) handleEventOverrides(w http.ResponseWriter, r *http.Request, log sLog) error { - eventId, err := strconv.Atoi(r.PathValue("event_id")) - if err != nil { - return newInvalidIdServiceError(err, "event_id") - } - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - cannotCreateOverride, err := srv.cannotUpdateEvent(classID) - if err != nil { - return err - } - if cannotCreateOverride { - return newBadRequestServiceError(errors.New("cannot create an event override for a completed or cancelled class"), "cannot create event override") - } - var overrides []*ProgramClassEventOverride - if err := json.NewDecoder(r.Body).Decode(&overrides); err != nil { - return newJSONReqBodyServiceError(err) - } - for i, j := 0, len(overrides); i < j; i++ { - overrides[i].EventID = uint(eventId) - overrides[i].CohortID = uint(classID) - } - // The room is only worth re-checking when it actually changes. The schedule's - // single-session editor always sends the CURRENT room_id alongside whatever field is - // being edited (useChangeEventField.ts: `room_id: event.room_id ?? null, ...fieldPatch`), - // so changing only the INSTRUCTOR used to run a room-only conflict check - // (CheckRRuleConflicts ignores InstructorID entirely) and report a room clash for an - // edit that never touched the room. - // Deliberately NOT fatal: this lookup is an optimisation, and the handler never needed - // the event row before. If it cannot be read we fall back to the old behaviour and run - // the check -- erring toward checking too much, never toward skipping a real conflict. - var currentRoomID *uint - if currentEvent, err := srv.store.GetEventById(eventId); err == nil { - currentRoomID = currentEvent.RoomID - } - roomChanged := func(override *ProgramClassEventOverride) bool { - if override.IsCancelled || override.RoomID == nil { - return false - } - if currentRoomID == nil { - return true - } - return *override.RoomID != *currentRoomID - } - - var class *ProgramClassCohort - for _, override := range overrides { - if roomChanged(override) { - var err error - class, err = srv.store.GetCohortByID(classID) - if err != nil { - return newDatabaseServiceError(err) - } - break - } - } - for _, override := range overrides { - if !roomChanged(override) { - continue - } - if _, err := srv.store.GetRoomByIDForFacility(*override.RoomID, class.FacilityID); err != nil { - return newDatabaseServiceError(err) - } - eventIDUint := uint(eventId) - classIDUint := uint(classID) - conflicts, err := srv.store.CheckRRuleConflicts(&ConflictCheckRequest{ - FacilityID: class.FacilityID, - RoomID: *override.RoomID, - RecurrenceRule: override.OverrideRrule, - Duration: override.Duration, - ExcludeEventID: &eventIDUint, - ExcludeClassID: &classIDUint, - }) - if err != nil { - return newDatabaseServiceError(err) - } - if len(conflicts) > 0 { - return writeConflictResponse(w, conflicts) - } - } - ctx := getQueryContext(r) - if err := srv.WithUserContext(r).CreateOverrideEvents(&ctx, overrides); err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, "Override(s) created successfully") -} - -type patchEventOverrideRequest struct { - Date string `json:"date"` - StartTime string `json:"start_time"` - IsCancelled bool `json:"is_cancelled"` - Reason string `json:"reason"` - RoomID *uint `json:"room_id"` - InstructorID *uint `json:"instructor_id"` - NewDate string `json:"new_date"` - NewStartTime string `json:"new_start_time"` - NewEndTime string `json:"new_end_time"` -} - -func (srv *server) handlePatchEventOverride(w http.ResponseWriter, r *http.Request, log sLog) error { - eventId, err := strconv.Atoi(r.PathValue("event_id")) - if err != nil { - return newInvalidIdServiceError(err, "event_id") - } - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - cannotCreateOverride, err := srv.cannotUpdateEvent(classID) - if err != nil { - return err - } - if cannotCreateOverride { - return newBadRequestServiceError(errors.New("cannot create an event override for a completed or cancelled class"), "cannot create event override") - } - var req patchEventOverrideRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return newJSONReqBodyServiceError(err) - } - if req.Date == "" { - return newBadRequestServiceError(errors.New("date is required"), "date is required") - } - if len(req.Reason) > 255 { - return newBadRequestServiceError(errors.New("reason exceeds maximum length of 255 characters"), "reason exceeds maximum length of 255 characters") - } - if req.InstructorID != nil { - class, err := srv.store.GetCohortByID(classID) - if err != nil { - return newDatabaseServiceError(err) - } - claims := claimsFrom(r) - allowedRoles := []users.Role{users.FacilityAdmin, users.DepartmentAdmin} - if claims.Role == users.FacilityAdmin { - allowedRoles = []users.Role{users.FacilityAdmin} - } - name, err := srv.store.GetInstructorNameByID(*req.InstructorID, class.FacilityID, allowedRoles...) - if err != nil { - return newDatabaseServiceError(err) - } - if name == "" { - return newForbiddenServiceError(errors.New("instructor not authorized for this facility"), "instructor not authorized for this facility") - } - } - ctx := getQueryContext(r) - event, err := srv.store.GetEventById(eventId) - if err != nil { - return newDatabaseServiceError(err) - } - eventRule, err := event.GetRRuleWithTimezone(ctx.Timezone) - if err != nil { - return newDatabaseServiceError(err) - } - // Use GetDTStart() (same as createEventInstances) to get the canonical time. - // OrigOptions.Dtstart.In(tz) can give a different hour due to DST on the - // DTSTART date vs the target date. GetDTStart() is what the instance generator uses. - eventStart := eventRule.GetDTStart() - canonicalHour := eventStart.Hour() - canonicalMinute := eventStart.Minute() - - buildRRule := func(dateStr string, optionalTime string) (string, error) { - dateOnly, err := time.Parse("2006-01-02", dateStr) - if err != nil { - return "", err - } - hour, minute := canonicalHour, canonicalMinute - if optionalTime != "" { - parts := strings.Split(optionalTime, ":") - if len(parts) == 2 { - hour, _ = strconv.Atoi(parts[0]) - minute, _ = strconv.Atoi(parts[1]) - } - } - cleanDate := dateOnly.Format("20060102") - return fmt.Sprintf("DTSTART;TZID=%s:%sT%02d%02d00\nRRULE:FREQ=DAILY;COUNT=1", - ctx.Timezone, cleanDate, hour, minute), nil - } - - originalRRule, err := buildRRule(req.Date, req.StartTime) - if err != nil { - return newBadRequestServiceError(err, "invalid date format") - } - - if req.NewDate != "" { - newRRule, err := buildRRule(req.NewDate, req.NewStartTime) - if err != nil { - return newBadRequestServiceError(err, "invalid new_date format") - } - newDuration := event.Duration - if req.NewStartTime != "" && req.NewEndTime != "" { - startParts := strings.Split(req.NewStartTime, ":") - endParts := strings.Split(req.NewEndTime, ":") - if len(startParts) == 2 && len(endParts) == 2 { - sh, _ := strconv.Atoi(startParts[0]) - sm, _ := strconv.Atoi(startParts[1]) - eh, _ := strconv.Atoi(endParts[0]) - em, _ := strconv.Atoi(endParts[1]) - dur := time.Duration(eh-sh)*time.Hour + time.Duration(em-sm)*time.Minute - if dur > 0 { - newDuration = dur.String() - } - } - } - newRoomID := event.RoomID - if req.RoomID != nil { - newRoomID = req.RoomID - } - newInstructorID := event.InstructorID - if req.InstructorID != nil { - newInstructorID = req.InstructorID - } - rescheduleReason := "rescheduled" - if req.Reason != "" { - rescheduleReason = req.Reason - } - overrides := []*ProgramClassEventOverride{ - { - EventID: uint(eventId), - CohortID: uint(classID), - Duration: event.Duration, - OverrideRrule: originalRRule, - IsCancelled: true, - Reason: StringPtr(rescheduleReason), - }, - { - EventID: uint(eventId), - CohortID: uint(classID), - Duration: newDuration, - OverrideRrule: newRRule, - IsCancelled: false, - RoomID: newRoomID, - InstructorID: newInstructorID, - }, - } - if err := srv.WithUserContext(r).CreateOverrideEvents(&ctx, overrides); err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, "Event rescheduled successfully") - } - - overrideDuration := event.Duration - overrideRRule := originalRRule - if req.NewStartTime != "" { - overrideRRule, err = buildRRule(req.Date, req.NewStartTime) - if err != nil { - return newBadRequestServiceError(err, "invalid start time") - } - if req.NewEndTime != "" { - startParts := strings.Split(req.NewStartTime, ":") - endParts := strings.Split(req.NewEndTime, ":") - if len(startParts) == 2 && len(endParts) == 2 { - sh, _ := strconv.Atoi(startParts[0]) - sm, _ := strconv.Atoi(startParts[1]) - eh, _ := strconv.Atoi(endParts[0]) - em, _ := strconv.Atoi(endParts[1]) - dur := time.Duration(eh-sh)*time.Hour + time.Duration(em-sm)*time.Minute - if dur > 0 { - overrideDuration = dur.String() - } - } - } - } - override := &ProgramClassEventOverride{ - EventID: uint(eventId), - CohortID: uint(classID), - Duration: overrideDuration, - OverrideRrule: overrideRRule, - IsCancelled: req.IsCancelled, - Reason: NilIfBlank(req.Reason), - RoomID: req.RoomID, - InstructorID: req.InstructorID, - } - if err := srv.WithUserContext(r).CreateOverrideEvents(&ctx, []*ProgramClassEventOverride{override}); err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, "Override created successfully") -} - -func (srv *server) handleDeleteEventOverride(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("event_override_id")) - if err != nil { - return newInvalidIdServiceError(err, "event override ID") - } - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - cannotDelete, err := srv.cannotUpdateEvent(classID) - if err != nil { - return err - } - if cannotDelete { - return newBadRequestServiceError(errors.New("cannot delete an event for a completed or cancelled class"), "cannot delete event") - } - log.add("class_id", classID) - log.add("event_override_id", id) - args := getQueryContext(r) - undoAppliedFuture := r.URL.Query().Get("undo_applied_future") == "true" - err = srv.WithUserContext(r).DeleteOverrideEvent(&args, id, classID, undoAppliedFuture) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusNoContent, "Event override(s) deleted successfully") -} - -func (srv *server) handleUncancelSeries(w http.ResponseWriter, r *http.Request, log sLog) error { - eventID, err := strconv.Atoi(r.PathValue("event_id")) - if err != nil { - return newInvalidIdServiceError(err, "event_id") - } - var req struct { - RestoreDate string `json:"restore_date"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return newJSONReqBodyServiceError(err) - } - if req.RestoreDate == "" { - return newBadRequestServiceError(errors.New("restore date is required"), "restore date is required") - } - ctx := getQueryContext(r) - if err := srv.store.UncancelEventSeries(&ctx, uint(eventID), req.RestoreDate); err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, "Series uncancelled successfully") -} - -func (srv *server) handleUncancelOverride(w http.ResponseWriter, r *http.Request, log sLog) error { - overrideID, err := strconv.Atoi(r.PathValue("event_override_id")) - if err != nil { - return newInvalidIdServiceError(err, "event override ID") - } - ctx := getQueryContext(r) - if err := srv.store.UncancelOverride(&ctx, uint(overrideID)); err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, "Override uncancelled successfully") -} - -func (srv *server) handleCreateEvent(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class_id") - } - cannotCreate, err := srv.cannotUpdateEvent(classID) - if err != nil { - return err - } - if cannotCreate { - return newBadRequestServiceError(errors.New("cannot create new event for a completed or cancelled class"), "cannot create event") - } - event := &ProgramClassEvent{} - if err := json.NewDecoder(r.Body).Decode(event); err != nil { - return newJSONReqBodyServiceError(err) - } - event.CohortID = uint(classID) - if event.RoomID != nil || event.InstructorID != nil { - class, err := srv.store.GetCohortByID(classID) - if err != nil { - return newDatabaseServiceError(err) - } - conflictReq := &ConflictCheckRequest{ - FacilityID: class.FacilityID, - RecurrenceRule: event.RecurrenceRule, - Duration: event.Duration, - } - if event.RoomID != nil { - if _, err := srv.store.GetRoomByIDForFacility(*event.RoomID, class.FacilityID); err != nil { - return newDatabaseServiceError(err) - } - conflictReq.RoomID = *event.RoomID - } - if event.InstructorID != nil { - if name, err := srv.store.GetInstructorNameByID(*event.InstructorID, class.FacilityID); err != nil || name == "" { - return newBadRequestServiceError(err, "invalid instructor for this facility") - } - conflictReq.InstructorID = *event.InstructorID - } - conflicts, err := srv.store.CheckConflicts(conflictReq) - if err != nil { - return newDatabaseServiceError(err) - } - if len(conflicts) > 0 { - return writeConflictResponse(w, conflicts) - } - } - _, err = srv.WithUserContext(r).CreateNewEvent(classID, event) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusCreated, "Event created successfully") -} - -func (srv *server) handleRescheduleEventSeries(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class_id") - } - cannotUpdate, err := srv.cannotUpdateEvent(classID) - if err != nil { - return err - } - if cannotUpdate { - return newBadRequestServiceError(errors.New("cannot reschedule event series for a completed or cancelled class"), "cannot update event") - } - var eventSeriesRequest struct { - EventSeries ProgramClassEvent `json:"event_series"` - ClosedEventSeries ProgramClassEvent `json:"closed_event_series"` - } - if err := json.NewDecoder(r.Body).Decode(&eventSeriesRequest); err != nil { - return newJSONReqBodyServiceError(err) - } - // A cancelled series frees the room, so skip the conflict check for cancellations. - if eventSeriesRequest.EventSeries.RoomID != nil { - class, err := srv.store.GetCohortByID(classID) - if err != nil { - return newDatabaseServiceError(err) - } - if _, err := srv.store.GetRoomByIDForFacility(*eventSeriesRequest.EventSeries.RoomID, class.FacilityID); err != nil { - return newDatabaseServiceError(err) - } - if !eventSeriesRequest.EventSeries.IsCancelled { - var excludeEventID *uint - if eventSeriesRequest.ClosedEventSeries.ID > 0 { - closedID := eventSeriesRequest.ClosedEventSeries.ID - excludeEventID = &closedID - } else if eventSeriesRequest.EventSeries.ID > 0 { - excludeEventID = &eventSeriesRequest.EventSeries.ID - } - conflicts, err := srv.store.CheckRRuleConflicts(&ConflictCheckRequest{ - FacilityID: class.FacilityID, - RoomID: *eventSeriesRequest.EventSeries.RoomID, - RecurrenceRule: eventSeriesRequest.EventSeries.RecurrenceRule, - Duration: eventSeriesRequest.EventSeries.Duration, - ExcludeEventID: excludeEventID, - }) - if err != nil { - return newDatabaseServiceError(err) - } - if len(conflicts) > 0 { - return writeConflictResponse(w, conflicts) - } - } - } - args := getQueryContext(r) - eventSeriesRequest.EventSeries.CohortID = uint(classID) - eventSeriesRequest.ClosedEventSeries.CohortID = uint(classID) - err = srv.WithUserContext(r).CreateRescheduleEventSeries(&args, uint(classID), eventSeriesRequest.EventSeries, eventSeriesRequest.ClosedEventSeries) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusCreated, "Event rescheduled successfully") -} - -func (srv *server) cannotUpdateEvent(classID int) (bool, error) { - class, err := srv.store.GetCohortByID(classID) - if err != nil { - return false, newDatabaseServiceError(err) - } - return class.Status == Completed || class.Status == Cancelled, nil -} - -func (srv *server) handleGetProgramClassEvents(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class_id") - } - if uint(classID) >= CanvasClassIDOffset { - args := getQueryContext(r) - return writePaginatedResponse(w, http.StatusOK, - []ProgramClassEvent{}, - newPaginationInfo(1, args.PerPage, 0)) - } - claims := claimsFrom(r) - var userId *int - if !claims.IsAdmin() { - tmp := int(claims.UserID) - userId = &tmp - } else { - userIdStr := r.URL.Query().Get("user_id") - if userIdStr != "" { - if parsedUserId, err := strconv.Atoi(userIdStr); err == nil { - userId = &parsedUserId - } else { - log.errorf("Error parsing user id: %v", err) - } - } - } - - log.add("class id", classID) - month := r.URL.Query().Get("month") - year := r.URL.Query().Get("year") - justDates := r.URL.Query().Get("dates") - if justDates == "true" { - timezone := getQueryContext(r).Timezone - // Check for optional event_id parameter to get dates for a specific event - eventIdStr := r.URL.Query().Get("event_id") - var eventId *int - if eventIdStr != "" { - if parsedEventId, err := strconv.Atoi(eventIdStr); err == nil { - eventId = &parsedEventId - } else { - log.errorf("Error parsing event_id: %v", err) - } - } - dates, err := srv.store.GetClassEventDatesForRecurrence(classID, timezone, month, year, eventId) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, dates) - } - - qryCtx := getQueryContext(r) - allInstances := r.URL.Query().Get("all") == "true" - instances, err := srv.store.GetClassEventInstancesWithAttendanceForRecurrence(classID, &qryCtx, month, year, userId, allInstances) - if err != nil { - return newDatabaseServiceError(err) - } - - return writePaginatedResponse(w, http.StatusOK, instances, qryCtx.IntoMeta()) -} diff --git a/backend/src/modules/programs/class_events_store.go b/backend/src/modules/programs/class_events_store.go deleted file mode 100644 index 184a12236..000000000 --- a/backend/src/modules/programs/class_events_store.go +++ /dev/null @@ -1,1760 +0,0 @@ -package programs - -import ( - "cmp" - "errors" - "regexp" - "slices" - "sort" - "strconv" - "strings" - "time" - - "github.com/sirupsen/logrus" - "github.com/teambition/rrule-go" - "gorm.io/gorm" -) - -/* -Everything stored in database will ALWAYS be in UTC -and converted when returning to the client -*/ - -func (db *Store) GetClassEvents(args *QueryContext, classId int) ([]ProgramClassEvent, error) { - events := []ProgramClassEvent{} - tx := db.Model(&ProgramClassEvent{}).Preload("Overrides").Where("cohort_id = ?", classId) - if !args.All { - if err := tx.Count(&args.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - tx = tx.Limit(args.PerPage).Offset(args.CalcOffset()) - } - if err := tx.Find(&events).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - return events, nil -} - -func (db *Store) CreateNewEvent(classId int, form *ProgramClassEvent) (*ProgramClassEvent, error) { - err := Validate().Struct(form) - if err != nil { - return nil, newCreateDBError(err, "program_class_event") - } - if err := db.Create(form).Error; err != nil { - return nil, newCreateDBError(err, "program_class_event") - } - return form, nil -} - -func (db *Store) GetEventById(eventId int) (*ProgramClassEvent, error) { - event := ProgramClassEvent{} - if err := db.Preload("Overrides").First(&event, eventId).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_event") - } - return &event, nil -} - -var dtStartDateRegex = regexp.MustCompile(`DTSTART[^\n:]*:(\d{8})`) - -func getRuleDTStartDate(rule string) string { - m := dtStartDateRegex.FindStringSubmatch(rule) - if len(m) < 2 { - return "" - } - return m[1] -} - -// ruleStartsAfterUntil reports whether DTSTART falls after UNTIL (a dead, zero-occurrence row). -func ruleStartsAfterUntil(rule string) bool { - start := getRuleDTStartDate(rule) - until := GetUntilDateFromRule(rule) - if start == "" || len(until) < 8 { - return false - } - return start > until[:8] -} - -// CreateRescheduleEventSeries applies a series-level reschedule or cancel from a cut -// date (closedSeries' UNTIL, = cut - 1). It reconciles every existing row so the new -// series owns all sessions on/after the cut: rows starting before the cut are capped to -// it, rows starting on/after it are soft-deleted. This stays correct when a class has -// multiple recurrence rows, not just the one being edited. -func (db *Store) CreateRescheduleEventSeries(ctx *QueryContext, classID uint, newSeries, closedSeries ProgramClassEvent) error { - untilDate := GetUntilDateFromRule(closedSeries.RecurrenceRule) - if len(untilDate) < 8 { - return newDBError(errors.New("closed_event_series is missing a valid UNTIL date"), "invalid event series request") - } - cutMinusOne := untilDate[:8] // YYYYMMDD of (cut date - 1) - - tx := db.WithContext(ctx.Ctx).Begin() - if tx.Error != nil { - return newDBError(tx.Error, "unable to start the database transaction") - } - - var existing []ProgramClassEvent - if err := tx.Where("cohort_id = ?", classID).Find(&existing).Error; err != nil { - tx.Rollback() - return newGetRecordsDBError(err, "program_class_events") - } - - for i := range existing { - row := existing[i] - if dtStart := getRuleDTStartDate(row.RecurrenceRule); dtStart != "" && dtStart > cutMinusOne { - if err := tx.Delete(&ProgramClassEvent{}, row.ID).Error; err != nil { - tx.Rollback() - return newUpdateDBError(err, "program_class_events") - } - continue - } - // Skip rows that already end on/before the cut; only ever shorten, never extend. - if existingUntil := GetUntilDateFromRule(row.RecurrenceRule); len(existingUntil) >= 8 && existingUntil[:8] <= cutMinusOne { - continue - } - cappedRule := ReplaceOrAddUntilDate(row.RecurrenceRule, untilDate) - if cappedRule == row.RecurrenceRule { - continue - } - if err := tx.Model(&ProgramClassEvent{}).Where("id = ?", row.ID).Update("recurrence_rule", cappedRule).Error; err != nil { - tx.Rollback() - return newUpdateDBError(err, "program_class_events") - } - } - - // Create the new series from the cut, unless it would be empty (DTSTART after UNTIL). - if newSeries.RecurrenceRule != "" && !ruleStartsAfterUntil(newSeries.RecurrenceRule) { - newSeries.ID = 0 - newSeries.CohortID = classID - logField := "event_rescheduled_series" - if newSeries.IsCancelled { - logField = "event_cancelled_series" - } - changeLogEntry := NewChangeLogEntry(TableNameCohort, logField, StringPtr(""), &newSeries.RecurrenceRule, classID, ctx.UserID) - if err := tx.Create(&newSeries).Error; err != nil { - tx.Rollback() - return newCreateDBError(err, "program_class_events") - } - if err := tx.Create(&changeLogEntry).Error; err != nil { - tx.Rollback() - return newCreateDBError(err, "change_log_entries") - } - } - - if err := tx.Commit().Error; err != nil { - return newDBError(err, "unable to commit the database transaction") - } - return nil -} - -// UncancelEventSeries restores a series-level cancellation from restoreDate onwards. -// It closes the cancelled series with UNTIL = restoreDate-1 and creates a new -// non-cancelled series starting from restoreDate with the same recurrence pattern. -func (db *Store) UncancelEventSeries(ctx *QueryContext, eventID uint, restoreDate string) error { - tx := db.WithContext(ctx.Ctx).Begin() - if tx.Error != nil { - return newDBError(tx.Error, "unable to start database transaction") - } - - var cancelledSeries ProgramClassEvent - if err := tx.First(&cancelledSeries, eventID).Error; err != nil { - tx.Rollback() - return newGetRecordsDBError(err, "program_class_events") - } - - restoreDt, err := time.Parse("2006-01-02", restoreDate) - if err != nil { - tx.Rollback() - return newDBError(err, "invalid restore_date") - } - untilStr := FormatDateForUntil(restoreDt.AddDate(0, 0, -1)) - - // Split the recurrence rule into DTSTART and RRULE lines - lines := strings.Split(cancelledSeries.RecurrenceRule, "\n") - var dtStartLine, rruleLine string - for _, l := range lines { - switch { - case strings.HasPrefix(l, "DTSTART"): - dtStartLine = l - case strings.HasPrefix(l, "RRULE:"): - rruleLine = l - } - } - - // Close the cancelled series: add UNTIL to the RRULE line - closedRrule := ReplaceOrAddUntilDate(rruleLine, untilStr) - var closedRule string - if dtStartLine != "" { - closedRule = dtStartLine + "\n" + closedRrule - } else { - closedRule = closedRrule - } - if err := tx.Model(&ProgramClassEvent{}).Where("id = ?", eventID). - Update("recurrence_rule", closedRule).Error; err != nil { - tx.Rollback() - return newUpdateDBError(err, "program_class_events") - } - - // Build new non-cancelled series starting from restoreDate - var newDtStartLine string - if dtStartLine != "" { - colonIdx := strings.LastIndex(dtStartLine, ":") - if colonIdx >= 0 && colonIdx+9 <= len(dtStartLine) { - newDtStartLine = dtStartLine[:colonIdx+1] + restoreDt.Format("20060102") + dtStartLine[colonIdx+9:] - } else { - newDtStartLine = dtStartLine - } - } - var newSeriesRule string - if newDtStartLine != "" { - newSeriesRule = newDtStartLine + "\n" + rruleLine - } else { - newSeriesRule = rruleLine - } - - newSeries := ProgramClassEvent{ - CohortID: cancelledSeries.CohortID, - Duration: cancelledSeries.Duration, - RecurrenceRule: newSeriesRule, - RoomID: cancelledSeries.RoomID, - InstructorID: cancelledSeries.InstructorID, - } - if err := tx.Create(&newSeries).Error; err != nil { - tx.Rollback() - return newCreateDBError(err, "program_class_events") - } - - return tx.Commit().Error -} - -// UncancelOverride flips an override from cancelled back to non-cancelled via a direct -// UPDATE. Used when undoing only the cancellation of a rescheduled class — the override -// stays in place (preserving the reschedule link) but is no longer cancelled. -func (db *Store) UncancelOverride(ctx *QueryContext, overrideID uint) error { - return db.WithContext(ctx.Ctx). - Model(&ProgramClassEventOverride{}). - Where("id = ?", overrideID). - Updates(map[string]interface{}{ - "is_cancelled": false, - "reason": "", - }).Error -} - -// Soft deletes ProgramClassEventOverride by its ID, removes any associated attendance records, and logs the change. -// If the ProgramClassEventOverride to be deleted has an exiting LinkedOverrideEventID (rescheduled event) that ProgramClassEventOverride is deleted -func (db *Store) DeleteOverrideEvent(args *QueryContext, eventID int, classID int, undoAppliedFuture bool) error { - trans := db.WithContext(args.Ctx).Begin() - if trans.Error != nil { - return newDBError(trans.Error, "unable to start the database transaction") - } - - var ( - changeLogEntry *ChangeLogEntry - eventDate *string - eventIDs = make([]uint, 0, 1) - override ProgramClassEventOverride - err error - ) - - if err := trans.Model(&ProgramClassEventOverride{}).Where("id = ?", eventID).Find(&override).Error; err != nil { - trans.Rollback() - return newGetRecordsDBError(err, "program_class_event_overrides") - } - - // Collect all overrides in the chain for deletion. - // Follows both directions: forward links (LinkedOverrideEventID) and reverse - // links (overrides linking TO this one). BFS traverses the full chain so - // undoing A in A→B→C deletes all three overrides. - eventIDs = append(eventIDs, override.ID) - visited := map[uint]bool{override.ID: true} - queue := []uint{override.ID} - // Seed the queue with the forward link if it exists - if override.LinkedOverrideEventID != nil { - fwd := *override.LinkedOverrideEventID - if !visited[fwd] { - visited[fwd] = true - eventIDs = append(eventIDs, fwd) - queue = append(queue, fwd) - } - } - for len(queue) > 0 { - currentID := queue[0] - queue = queue[1:] - // Reverse links: overrides that point TO currentID - var reverseLinked []ProgramClassEventOverride - if err := trans.Where("linked_override_event_id = ?", currentID).Find(&reverseLinked).Error; err == nil { - for _, rl := range reverseLinked { - if !visited[rl.ID] { - visited[rl.ID] = true - eventIDs = append(eventIDs, rl.ID) - queue = append(queue, rl.ID) - } - } - } - // Forward link: the override that currentID points to - var current ProgramClassEventOverride - if err := trans.Where("id = ?", currentID).First(¤t).Error; err == nil { - if current.LinkedOverrideEventID != nil { - fwd := *current.LinkedOverrideEventID - if !visited[fwd] { - visited[fwd] = true - eventIDs = append(eventIDs, fwd) - queue = append(queue, fwd) - } - } - } - } - - eventDate, err = override.GetFormattedOverrideDate("2006-01-02") - if err != nil { - trans.Rollback() - return newDBError(err, "unable to parse override date") - } - - if err := deleteEventAttedanceByDate(trans, override.EventID, *eventDate); err != nil { - trans.Rollback() - return err - } - - if err := trans.Delete(&ProgramClassEventOverride{}, "id IN (?)", eventIDs).Error; err != nil { - trans.Rollback() - return newDeleteDBError(err, "program class event override") - } - - if err := db.syncClassDateBoundaries(trans, uint(classID)); err != nil { - trans.Rollback() - return err - } - - changeLogEntry = NewChangeLogEntry(TableNameCohort, "event_restored", &override.OverrideRrule, StringPtr(""), uint(classID), args.UserID) - if err := trans.Create(&changeLogEntry).Error; err != nil { - trans.Rollback() - return newCreateDBError(err, "change_log_entries") - } - - if err := trans.Commit().Error; err != nil { - return newDBError(err, "unable to commit the database transaction") - } - return nil -} - -// Creates multiple ProgramClassEventOverride records within a single database transaction. It performs the following operations based upon conditions: -// - Cancels an event -// - Links two override events together for rescheduling purposes. -// - Deletes attendance records for events that have been rescheduled or cancelled. -// - Logs a single change -func (db *Store) CreateOverrideEvents(ctx *QueryContext, overrideEvents []*ProgramClassEventOverride) error { - trans := db.WithContext(ctx.Ctx).Begin() //only need to pass context here, it will be used/shared within the transaction - if trans.Error != nil { - return newDBError(trans.Error, "unable to start the database transaction") - } - - var ( - changeLogEntry *ChangeLogEntry - eventDate *string - err error - linkedOverrideID *uint - isOverrideUpdate bool - ) - - changeLogEntry = NewChangeLogEntry(TableNameCohort, "", nil, nil, 0, ctx.UserID) - var parentCancelIDs []uint - for _, overrideEvent := range overrideEvents { - if !overrideEvent.IsCancelled && linkedOverrideID != nil { - overrideEvent.LinkedOverrideEventID = linkedOverrideID - // Clean up old reschedule targets: when re-rescheduling (A→B then B→C), - // the old target (B's non-cancelled override) must be removed so only - // the new target (C) remains linked to A's cancel override. - if err := trans.Where("linked_override_event_id = ? AND is_cancelled = false AND id != ? AND deleted_at IS NULL", - *linkedOverrideID, overrideEvent.ID). - Delete(&ProgramClassEventOverride{}).Error; err != nil { - trans.Rollback() - return newDBError(err, "unable to clean up old reschedule targets") - } - } - if overrideEvent.ID == 0 { - var existing ProgramClassEventOverride - if err := trans.Unscoped(). - Where("event_id = ? AND override_rrule = ? AND deleted_at IS NULL", - overrideEvent.EventID, overrideEvent.OverrideRrule). - First(&existing).Error; err == nil { - // If the existing override is a reschedule target linked from a cancel, - // remember the parent cancel so we can preserve it after the upsert - if existing.LinkedOverrideEventID != nil && *existing.LinkedOverrideEventID != existing.ID { - parentCancelIDs = append(parentCancelIDs, *existing.LinkedOverrideEventID) - } - overrideEvent.ID = existing.ID - // Preserve the chain link when overwriting a reschedule target with a cancel. - // Example: After A→B, B's override has linked=A_cancel. When B is cancelled, - // the incoming cancel has linked=nil. Carry forward so the A←B chain survives. - if overrideEvent.LinkedOverrideEventID == nil && existing.LinkedOverrideEventID != nil { - overrideEvent.LinkedOverrideEventID = existing.LinkedOverrideEventID - } - } - } - isOverrideUpdate = overrideEvent.ID > 0 - if isOverrideUpdate { - // Existing override found — do a direct UPDATE to avoid GORM's - // soft-delete behavior with Create+OnConflict which deletes the - // existing record and creates a new one (breaking chain links). - if err := trans.Model(&ProgramClassEventOverride{}). - Where("id = ?", overrideEvent.ID). - Updates(map[string]interface{}{ - "duration": overrideEvent.Duration, - "override_rrule": overrideEvent.OverrideRrule, - "is_cancelled": overrideEvent.IsCancelled, - "room_id": overrideEvent.RoomID, - "reason": overrideEvent.Reason, - "linked_override_event_id": overrideEvent.LinkedOverrideEventID, - "instructor_id": overrideEvent.InstructorID, - }).Error; err != nil { - trans.Rollback() - return newCreateDBError(err, "program_class_event_overrides") - } - } else { - if err := trans.Create(&overrideEvent).Error; err != nil { - trans.Rollback() - return newCreateDBError(err, "program_class_event_overrides") - } - } - if overrideEvent.IsCancelled && len(overrideEvents) < 2 { - changeLogEntry.FieldName = "event_cancelled" - changeLogEntry.OldValue = StringPtr("") - changeLogEntry.ParentRefID = overrideEvent.CohortID - changeLogEntry.NewValue = &overrideEvent.OverrideRrule - } else if !overrideEvent.IsCancelled && len(overrideEvents) > 1 { - eventSummary, err := overrideEvent.GetRescheduleSummary(ctx.Timezone) - if err != nil { - trans.Rollback() - return newDBError(err, "unable to parse event summary") - } - changeLogEntry.FieldName = "event_rescheduled" - changeLogEntry.ParentRefID = overrideEvent.CohortID - changeLogEntry.NewValue = eventSummary - } else if overrideEvent.IsCancelled { - linkedOverrideID = &overrideEvent.ID - changeLogEntry.OldValue = &overrideEvent.OverrideRrule - } - if !overrideEvent.IsCancelled && overrideEvent.InstructorID != nil { - var instructor User - if err := trans.Select("name_first", "name_last").First(&instructor, *overrideEvent.InstructorID).Error; err == nil { - eventDate, _ := overrideEvent.GetFormattedOverrideDate("1/02/2006") - summary := instructor.NameFirst + " " + instructor.NameLast + " on " + *eventDate - changeLogEntry.FieldName = "event_substitute_instructor" - changeLogEntry.ParentRefID = overrideEvent.CohortID - changeLogEntry.NewValue = &summary - } - } - if !overrideEvent.IsCancelled && overrideEvent.RoomID != nil && len(overrideEvents) == 1 { - var room Room - if err := trans.Select("name").First(&room, *overrideEvent.RoomID).Error; err == nil { - eventDate, _ := overrideEvent.GetFormattedOverrideDate("1/02/2006") - summary := room.Name + " on " + *eventDate - changeLogEntry.FieldName = "event_room_changed" - changeLogEntry.ParentRefID = overrideEvent.CohortID - changeLogEntry.NewValue = &summary - } - } - if overrideEvent.IsCancelled || isOverrideUpdate { //delete attendance - eventDate, err = overrideEvent.GetFormattedOverrideDate("2006-01-02") - if err != nil { - trans.Rollback() - return newDBError(err, "unable to parse override date") - } - if err := deleteEventAttedanceByDate(trans, overrideEvent.EventID, *eventDate); err != nil { - trans.Rollback() - return err - } - } - } - - // Restore parent cancel overrides that may have been soft-deleted by the upsert. - // This preserves the A→B→C chain: when rescheduling B→C, A's cancel must survive. - if len(parentCancelIDs) > 0 { - if err := trans.Unscoped().Model(&ProgramClassEventOverride{}). - Where("id IN ?", parentCancelIDs). - Update("deleted_at", nil).Error; err != nil { - trans.Rollback() - return newDBError(err, "unable to restore parent cancel overrides") - } - } - - if err := trans.Create(&changeLogEntry).Error; err != nil { - trans.Rollback() - return newCreateDBError(err, "change_log_entries") - } - - if len(overrideEvents) > 0 { - if err := db.syncClassDateBoundaries(trans, overrideEvents[0].CohortID); err != nil { - trans.Rollback() - return err - } - } - - //end transaction - if err := trans.Commit().Error; err != nil { - return newDBError(err, "unable to commit the database transaction") - } - return nil -} - -func (db *Store) syncClassDateBoundaries(trans *gorm.DB, classID uint) error { - // Boundaries must be derived from all rows, since a class can have multiple. - var events []ProgramClassEvent - if err := trans.Preload("Overrides").Where("cohort_id = ?", classID).Order("created_at ASC").Find(&events).Error; err != nil { - return newGetRecordsDBError(err, "program_class_events") - } - if len(events) == 0 { - return newGetRecordsDBError(gorm.ErrRecordNotFound, "program_class_events") - } - - var class ProgramClassCohort - if err := trans.First(&class, "id = ?", classID).Error; err != nil { - return newGetRecordsDBError(err, "program class cohort") - } - - startBoundary := class.StartDt - endBoundary := startBoundary.AddDate(5, 0, 0) - if class.EndDt != nil { - endBoundary = class.EndDt.AddDate(0, 3, 0) - } - - var originalBaseStart, ruleUntil, earliestReschedule time.Time - allDates := make([]time.Time, 0) - hasOverrides := false - - for i := range events { - event := events[i] - rRule, err := event.GetRRule() - if err != nil { - return err - } - - baseStart := rRule.OrigOptions.Dtstart.In(time.UTC) - if originalBaseStart.IsZero() || baseStart.Before(originalBaseStart) { - originalBaseStart = baseStart - } - // rrule-go's GetUntil() returns a far-future date (~year 2318) instead of zero - // when no UNTIL is present, so check the raw string for an explicit UNTIL clause - if strings.Contains(event.RecurrenceRule, "UNTIL=") { - if u := rRule.GetUntil(); u.After(ruleUntil) { - ruleUntil = u - } - } - - baseOccurrences := rRule.Between(startBoundary, endBoundary, true) - - if len(event.Overrides) > 0 { - hasOverrides = true - } - cancelledDates := make(map[string]bool) - rescheduledDates := make([]time.Time, 0) - for _, override := range event.Overrides { - overrideRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("unable to parse override rule: %s", override.OverrideRrule) - continue - } - occurrences := overrideRule.All() - if len(occurrences) == 0 { - continue - } - - overrideDate := occurrences[0].In(time.UTC) - if override.IsCancelled { - cancelledDates[overrideDate.Format("2006-01-02")] = true - continue - } - - rescheduledDates = append(rescheduledDates, overrideDate) - if earliestReschedule.IsZero() || overrideDate.Before(earliestReschedule) { - earliestReschedule = overrideDate - } - } - - for _, occurrence := range baseOccurrences { - if !cancelledDates[occurrence.Format("2006-01-02")] { - allDates = append(allDates, occurrence) - } - } - allDates = append(allDates, rescheduledDates...) - } - - if len(allDates) == 0 { - return nil - } - - sort.Slice(allDates, func(i, j int) bool { - return allDates[i].Before(allDates[j]) - }) - - computedEnd := allDates[len(allDates)-1] - - updates := make(map[string]any) - - targetStart := originalBaseStart - if !earliestReschedule.IsZero() && earliestReschedule.Before(originalBaseStart) { - targetStart = earliestReschedule - } - if !targetStart.Equal(class.StartDt) { - updates["start_dt"] = targetStart - } - - if class.EndDt == nil || !computedEnd.Equal(*class.EndDt) { - if (!hasOverrides && !ruleUntil.IsZero()) || (!ruleUntil.IsZero() && ruleUntil.After(computedEnd)) { - updates["end_dt"] = ruleUntil - } else { - updates["end_dt"] = computedEnd - } - } else if (!hasOverrides && !ruleUntil.IsZero() && !ruleUntil.Equal(*class.EndDt)) || (!ruleUntil.IsZero() && ruleUntil.After(*class.EndDt)) { - updates["end_dt"] = ruleUntil - } - - if len(updates) == 0 { - return nil - } - - if err := trans.Model(&ProgramClassCohort{}).Where("id = ?", classID).Updates(updates).Error; err != nil { - return newUpdateDBError(err, "program class cohort") - } - - return nil -} - -func deleteEventAttedanceByDate(trans *gorm.DB, eventID uint, eventDate string) error { - if err := trans.Unscoped().Where("event_id = ? AND date = ?", eventID, eventDate).Delete(&ProgramClassEventAttendance{}).Error; err != nil { - return newDeleteDBError(err, "class_event_attendance") - } - return nil -} - -func (db *Store) NewEventOverride(eventId int, form *OverrideForm) (*ProgramClassEventOverride, error) { - event, err := db.GetEventById(eventId) - if err != nil { - return nil, newGetRecordsDBError(err, "program_class_event") - } - override := ProgramClassEventOverride{ - EventID: uint(eventId), - } - if form.RoomID != nil { - override.RoomID = form.RoomID - } - if form.Duration != "" { - override.Duration = form.Duration - } - if form.IsCancelled { - override.IsCancelled = true - } - var rruleStr string - switch form.OverrideType { - case OverrideAll: - if form.IsCancelled { - rruleOptions := rrule.ROption{ - Freq: rrule.DAILY, - Count: 0, // No occurrences - Dtstart: time.Now(), - } - rule, err := rrule.NewRRule(rruleOptions) - if err != nil { - return nil, newCreateDBError(err, "program_class_event_override") - } - rruleStr = rule.String() - } else { - until, err := event.RRuleUntil() - if err != nil { - return nil, newGetRecordsDBError(err, "program_class_event") - } - rruleOptions := rrule.ROption{ - Freq: rrule.WEEKLY, - Dtstart: time.Now(), - Until: until, - Byweekday: []rrule.Weekday{rrule.MO, rrule.TU, rrule.WE, rrule.TH, rrule.FR, rrule.SA, rrule.SU}, // Modify as needed - } - rule, err := rrule.NewRRule(rruleOptions) - if err != nil { - return nil, newCreateDBError(err, "program_class_event_override") - } - rruleStr = rule.String() - } - case OverrideSelf: - overrideDateOnly, err := time.ParseInLocation("2006-01-02", form.Date, time.UTC) - if err != nil { - return nil, newCreateDBError(err, "program_class_event_override") - } - - eventRule, err := event.GetRRule() - if err != nil { - return nil, newCreateDBError(err, "program_class_event") - } - eventDtstartUTC := eventRule.OrigOptions.Dtstart.In(time.UTC) - - overrideDate := time.Date( - overrideDateOnly.Year(), overrideDateOnly.Month(), overrideDateOnly.Day(), - eventDtstartUTC.Hour(), eventDtstartUTC.Minute(), eventDtstartUTC.Second(), eventDtstartUTC.Nanosecond(), - time.UTC, - ) - - rruleOptions := rrule.ROption{ - Freq: rrule.DAILY, - Count: 1, - Dtstart: overrideDate, - } - rule, err := rrule.NewRRule(rruleOptions) - if err != nil { - return nil, newCreateDBError(err, "program_class_event_override") - } - rruleStr = rule.String() - - case OverrideForwards: - overrideDate, err := time.Parse("2006-01-02", form.Date) - if err != nil { - return nil, newCreateDBError(err, "program_class_event_override") - } - rule, err := event.GetRRule() - if err != nil { - return nil, newCreateDBError(err, "program_class_event_override") - } - rruleOptions := rrule.ROption{ - Freq: rule.Options.Freq, - Dtstart: overrideDate, - Until: rule.Options.Until, - } - rule, err = rrule.NewRRule(rruleOptions) - if err != nil { - return nil, newCreateDBError(err, "program_class_event_override") - } - rruleStr = rule.String() - default: - return nil, newCreateDBError(err, "program_class_event_override") - } - override.OverrideRrule = rruleStr - if err := db.Create(&override).Error; err != nil { - return nil, newCreateDBError(err, "program_class_event_override") - } - return &override, nil -} - -func (db *Store) GetFacilityCalendar(args *QueryContext, dtRng *DateRange, classID int) ([]FacilityProgramClassEvent, error) { - events := make([]FacilityProgramClassEvent, 0, 10) - tx := db.WithContext(args.Ctx).Table("program_class_events pcev"). - Select(`pcev.id, pcev.created_at, pcev.updated_at, pcev.deleted_at, pcev.cohort_id, pcev.duration, pcev.recurrence_rule, pcev.room_id, - pcev.instructor_id, pcev.is_cancelled, pcev.reason, - r.name as room, - c.program_id as program_id, - p.name as program_name, - COALESCE(iu.name_first || ' ' || iu.name_last, '') as instructor_name, - cl.name as class_name, - c.status as class_status, - STRING_AGG(CONCAT(u.id, ':', u.name_last, ', ', u.name_first), '|' ORDER BY u.name_last) FILTER (WHERE e.enrollment_status = 'Enrolled') AS enrolled_users`). - Joins("JOIN program_class_cohorts c ON c.id = pcev.cohort_id AND c.archived_at IS NULL AND c.deleted_at IS NULL"). - Joins("JOIN program_classes cl ON cl.id = c.class_id"). - Joins("JOIN programs p ON p.id = c.program_id"). - Where("pcev.deleted_at IS NULL"). - Joins("LEFT JOIN rooms r ON r.id = pcev.room_id"). - Joins("LEFT JOIN users iu ON iu.id = pcev.instructor_id"). - Joins("LEFT JOIN program_class_enrollments e ON e.cohort_id = c.id"). - Joins("LEFT JOIN users u ON e.user_id = u.id"). - Where("c.facility_id = ?", args.FacilityID) - - if classID > 0 { - tx = tx.Where("c.id = ?", classID) - } - if !args.IsAdmin { - tx = tx.Where("u.id = ? AND e.enrollment_status = 'Enrolled'", args.UserID) - } - tx = tx.Group("pcev.id, iu.name_first, iu.name_last, cl.name, c.status, c.program_id, p.name, r.name") - if err := tx.Scan(&events).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - - eventIDs := make([]uint, 0, len(events)) - for _, event := range events { //gathering all for next query - eventIDs = append(eventIDs, event.ID) - } - - overrideEvents, err := db.GetProgramClassEventOverrides(args, eventIDs...) - if err != nil { - return nil, err - } - - overrideEventMap := make(map[uint][]ProgramClassEventOverride) - for _, overrideEvent := range overrideEvents { //easy organization - overrideEventMap[overrideEvent.EventID] = append(overrideEventMap[overrideEvent.EventID], overrideEvent) - } - - facilityEvents := make([]FacilityProgramClassEvent, 0, 10) - for _, event := range events { - rRule, err := event.GetRRuleWithTimezone(args.Timezone) - if err != nil { - return nil, err - } - occurrences := rRule.Between(dtRng.Start, dtRng.End, true) - duration, err := time.ParseDuration(event.Duration) - if err != nil { - return nil, err - } - - //daylight saving time fix (VIP) prevents the time shift - canonicalHour, canonicalMinute := getCanonicalHourAndMinute(occurrences, args.Timezone) - overrides := overrideEventMap[event.ID] - var ( - isCancelled, isRescheduled, isRoomOrInstructorOverride bool - overrideID uint - overrideReason string - ) - userLocation, _ := time.LoadLocation(args.Timezone) - for _, occurrence := range occurrences { - localOccurrence := occurrence.In(userLocation) - consistentOccurrence := time.Date( - localOccurrence.Year(), - localOccurrence.Month(), - localOccurrence.Day(), - canonicalHour, - canonicalMinute, - 0, - 0, - userLocation, - ) - // Series-level cancellation: every occurrence is cancelled, bypass override logic - if event.IsCancelled { - endTime := consistentOccurrence.Add(duration) - pce := event.ProgramClassEvent - if event.Reason != nil { - r := *event.Reason - pce.Reason = &r - } - facilityEvent := FacilityProgramClassEvent{ - ProgramClassEvent: pce, - Room: event.Room, - InstructorName: event.InstructorName, - ProgramID: event.ProgramID, - ProgramName: event.ProgramName, - ClassName: event.ClassName, - EnrolledUsers: event.EnrolledUsers, - ClassStatus: event.ClassStatus, - StartTime: &consistentOccurrence, - EndTime: &endTime, - Frequency: rRule.OrigOptions.Freq.String(), - IsCancelled: true, - } - facilityEvents = append(facilityEvents, facilityEvent) - continue - } - - isCancelled, isRescheduled, isRoomOrInstructorOverride, overrideID, overrideReason = checkEventCancelledAndRescheduled(consistentOccurrence, overrides, args.Timezone) - - if (isCancelled && isRescheduled) || isRoomOrInstructorOverride { - continue - } - - endTime := consistentOccurrence.Add(duration) - pce := event.ProgramClassEvent - if isCancelled && overrideReason != "" { - r := overrideReason - pce.Reason = &r - } - facilityEvent := FacilityProgramClassEvent{ - ProgramClassEvent: pce, - Room: event.Room, - InstructorName: event.InstructorName, - ProgramID: event.ProgramID, - ProgramName: event.ProgramName, - ClassName: event.ClassName, - EnrolledUsers: event.EnrolledUsers, - ClassStatus: event.ClassStatus, - StartTime: &consistentOccurrence, - EndTime: &endTime, - Frequency: rRule.OrigOptions.Freq.String(), - IsCancelled: isCancelled, - IsOverride: isCancelled, - OverrideID: overrideID, - } - facilityEvents = append(facilityEvents, facilityEvent) - } - - linkedOverridesMap := getLinkedOverrideEventMap(event, overrides) - for _, override := range overrides { //adding the rescheduled ones to instances slice - if override.IsCancelled && override.LinkedOverrideEventID == nil { //skip only cancelled ones with no links - continue - } - rRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("unable to parse reschedule override rule: %s", override.OverrideRrule) - continue - } - if len(rRule.All()) == 0 { - logrus.Warnf("cancelled override rule does not contain a date instance, rule is: %s", override.OverrideRrule) - continue - } - overrideDate := rRule.All()[0] - duration, err := time.ParseDuration(override.Duration) - if err != nil { - logrus.Errorf("error parsing duration for event: %v", err) - } - - userLocation, _ := time.LoadLocation(args.Timezone) - localOverrideTime := overrideDate.In(userLocation) - consistentOverrideDate := time.Date( - localOverrideTime.Year(), - localOverrideTime.Month(), - localOverrideTime.Day(), - localOverrideTime.Hour(), - localOverrideTime.Minute(), - 0, - 0, - userLocation, - ) - overrideEndTime := consistentOverrideDate.Add(duration) - var linkedOverrideEvent *FacilityProgramClassEvent - if override.LinkedOverrideEventID != nil { - linkedOverrideEvent = linkedOverridesMap[*override.LinkedOverrideEventID] - } - pce := event.ProgramClassEvent - roomName := event.Room - originalRoomName := "" - instructorName := event.InstructorName - originalInstructorName := "" - if override.RoomRef != nil { - newRoomName := override.RoomRef.Name - if newRoomName != event.Room { - originalRoomName = event.Room - } - roomName = newRoomName - pce.RoomID = override.RoomID - } - if override.InstructorID != nil { - pce.InstructorID = override.InstructorID - if override.Instructor != nil { - newInstructorName := strings.TrimSpace(override.Instructor.NameFirst + " " + override.Instructor.NameLast) - if newInstructorName != event.InstructorName { - originalInstructorName = event.InstructorName - } - instructorName = newInstructorName - } else if event.InstructorID == nil || *event.InstructorID != *override.InstructorID { - originalInstructorName = event.InstructorName - instructorName = "Unknown Instructor" - } - } - facilityEvent := FacilityProgramClassEvent{ - ProgramClassEvent: pce, - Room: roomName, - OriginalRoom: originalRoomName, - InstructorName: instructorName, - OriginalInstructorName: originalInstructorName, - ProgramID: event.ProgramID, - ProgramName: event.ProgramName, - ClassName: event.ClassName, - EnrolledUsers: event.EnrolledUsers, - ClassStatus: event.ClassStatus, - StartTime: &consistentOverrideDate, - EndTime: &overrideEndTime, - Frequency: rRule.OrigOptions.Freq.String(), - IsOverride: true, - IsCancelled: override.IsCancelled, - OverrideID: override.ID, - LinkedOverrideEvent: linkedOverrideEvent, - } - facilityEvents = append(facilityEvents, facilityEvent) - } - } - slices.SortFunc(facilityEvents, func(a, b FacilityProgramClassEvent) int { - return b.StartTime.Compare(*a.StartTime) - }) - - return facilityEvents, nil -} - -// returns the hour and minute from the first occurrence within the slice of time.Time instances -func getCanonicalHourAndMinute(occurrences []time.Time, timezone string) (int, int) { - var canonicalHour, canonicalMinute int - if len(occurrences) > 0 { - firstOccurrence := occurrences[0] - userLocation, err := time.LoadLocation(timezone) - if err == nil { - localTime := firstOccurrence.In(userLocation) - canonicalHour = localTime.Hour() - canonicalMinute = localTime.Minute() - } else { - logrus.Warnf("unable to load the user's timezone while trying to get the canonical hour and minute. Error is: %v", err) - canonicalHour = firstOccurrence.Hour() - canonicalMinute = firstOccurrence.Minute() - } - } - return canonicalHour, canonicalMinute -} - -func getLinkedOverrideEventMap(event FacilityProgramClassEvent, overrides []ProgramClassEventOverride) map[uint]*FacilityProgramClassEvent { - overrideEventMap := make(map[uint]*FacilityProgramClassEvent) - for _, override := range overrides { - if override.LinkedOverrideEventID != nil { - overrideEventMap[*override.LinkedOverrideEventID] = &FacilityProgramClassEvent{ - ProgramClassEvent: event.ProgramClassEvent, - InstructorName: event.InstructorName, - ProgramName: event.ProgramName, - ClassName: event.ClassName, - EnrolledUsers: event.EnrolledUsers, - } - continue - } - - if override.IsCancelled && FormatNullableString(override.Reason) == "rescheduled" { - programClassEvent, ok := overrideEventMap[override.ID] - if !ok { - continue - } - rRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("unable to parse reschedule override rule: %s", override.OverrideRrule) - continue - } - if len(rRule.All()) == 0 { - logrus.Warnf("cancelled override rule does not contain a date instance, rule is: %s", override.OverrideRrule) - continue - } - overrideDate := rRule.All()[0] - duration, err := time.ParseDuration(override.Duration) - if err != nil { - logrus.Errorf("error parsing duration for event: %v", err) - } - overrideEndTime := overrideDate.Add(duration) - programClassEvent.StartTime = &overrideDate - programClassEvent.EndTime = &overrideEndTime - programClassEvent.Frequency = rRule.OrigOptions.Freq.String() - programClassEvent.IsOverride = true - programClassEvent.IsCancelled = true - programClassEvent.OverrideID = override.ID - overrideEventMap[override.ID] = programClassEvent - } - } - return overrideEventMap -} - -func checkEventCancelledAndRescheduled(occurrence time.Time, overrides []ProgramClassEventOverride, timezone string) (bool, bool, bool, uint, string) { - var ( - isCancelled = false - isRescheduled = false - isRoomOrInstructor = false - overrideID uint - overrideReason string - ) - - for _, override := range overrides { - if !override.IsCancelled && override.LinkedOverrideEventID != nil { //skip - continue - } - rRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("unable to parse override rule: %s", override.OverrideRrule) - continue - } - if len(rRule.All()) == 0 { - logrus.Warnf("override rule does not contain a date instance, rule is: %s", override.OverrideRrule) - continue - } - overrideDate := rRule.All()[0] - - //START day light savings time fix here - userLocation, _ := time.LoadLocation(timezone) - localOverrideTime := overrideDate.In(userLocation) - canonicalOverrideHour := localOverrideTime.Hour() - canonicalOverrideMinute := localOverrideTime.Minute() - consistentOverrideDate := time.Date( - localOverrideTime.Year(), - localOverrideTime.Month(), - localOverrideTime.Day(), - canonicalOverrideHour, - canonicalOverrideMinute, - 0, 0, - userLocation, - ) - //END day light savings time fix here - if consistentOverrideDate.Equal(occurrence) { - isRescheduled = FormatNullableString(override.Reason) == "rescheduled" - isCancelled = override.IsCancelled - isRoomOrInstructor = !override.IsCancelled && !isRescheduled - overrideID = override.ID - overrideReason = FormatNullableString(override.Reason) - break - } - } - return isCancelled, isRescheduled, isRoomOrInstructor, overrideID, overrideReason -} - -func applyOverrides(event ProgramClassEvent, start, end time.Time) []EventInstance { - var instances []EventInstance - mainSet := rrule.Set{} - - duration, err := time.ParseDuration(event.Duration) - if err != nil { - logrus.Errorf("Error parsing duration: %s", err) - duration = time.Hour // default duration - } - - mainRuleOptions, err := rrule.StrToROption(event.RecurrenceRule) - if err != nil { - logrus.Errorf("Error parsing main event RRULE: %s", err) - return instances - } - mainRuleOptions.Dtstart = mainRuleOptions.Dtstart.In(time.UTC) - mainRule, err := rrule.NewRRule(*mainRuleOptions) - if err != nil { - logrus.Errorf("Error creating main RRULE: %s", err) - return instances - } - mainSet.RRule(mainRule) - - var rDates []EventInstance - - for _, override := range event.Overrides { - overrideOptions, err := rrule.StrToROption(override.OverrideRrule) - if err != nil { - logrus.Errorf("Error parsing override RRULE: %s", err) - continue - } - overrideOptions.Dtstart = overrideOptions.Dtstart.In(time.UTC) - overrideRule, err := rrule.NewRRule(*overrideOptions) - if err != nil { - logrus.Errorf("Error creating override RRULE: %s", err) - continue - } - overrideOccurrences := overrideRule.Between(start.UTC(), end.UTC(), true) - if override.IsCancelled { - for _, occ := range overrideOccurrences { - mainSet.ExDate(occ.UTC()) - } - } else { - for _, occ := range overrideOccurrences { - roomName := getRoomName(event.RoomRef) - rDateInstance := EventInstance{ - EventID: event.ID, - CohortID: event.CohortID, - Duration: duration, - StartTime: occ.UTC(), - IsCancelled: false, - Room: roomName, - } - if override.Duration != "" { - newDuration, err := time.ParseDuration(override.Duration) - if err == nil { - rDateInstance.Duration = newDuration - } - } - if override.RoomRef != nil { - rDateInstance.Room = override.RoomRef.Name - } - rDates = append(rDates, rDateInstance) - mainSet.ExDate(occ.UTC()) - } - } - } - occurrences := mainSet.Between(start.UTC(), end.UTC(), true) - - roomName := getRoomName(event.RoomRef) - for _, occ := range occurrences { - instance := EventInstance{ - EventID: event.ID, - CohortID: event.CohortID, - StartTime: occ.UTC(), - Duration: duration, - IsCancelled: false, - Room: roomName, - } - instances = append(instances, instance) - } - - instances = append(instances, rDates...) - sort.Slice(instances, func(i, j int) bool { - return instances[i].StartTime.Before(instances[j].StartTime) - }) - - return instances -} - -func generateEventInstances(event ProgramClassEvent, startDate, endDate time.Time) []EventInstance { - eventInstances := applyOverrides(event, startDate, endDate) - return eventInstances -} - -func GenerateEventInstances(event ProgramClassEvent, startDate, endDate time.Time) []EventInstance { - return generateEventInstances(event, startDate, endDate) -} - -// GetClassEventInstancesWithAttendanceForRecurrence returns all occurrences for events -// for a given class based on each event's recurrence rule (from DTSTART until UNTIL) -// along with their associated attendance records. -func (db *Store) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qryCtx *QueryContext, month, year string, userId *int, allInstances bool) ([]ClassEventInstance, error) { - loc, err := time.LoadLocation(qryCtx.Timezone) - if err != nil { - logrus.Error("failed to load timezone") - return nil, newDBError(err, "failed to load timezone") - } - - // Load all rows, not just the newest: "apply to all future sessions" leaves a class - // with multiple rows whose RRULEs together describe the full schedule. - var events []ProgramClassEvent - if err := db.WithContext(qryCtx.Ctx). - Model(&ProgramClassEvent{}). - Preload("Overrides"). - Where("cohort_id = ?", classId). - Order("created_at ASC"). - Find(&events).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - if len(events) == 0 { - return []ClassEventInstance{}, nil - } - - type parsedEvent struct { - event ProgramClassEvent - rule *rrule.RRule - } - parsed := make([]parsedEvent, 0, len(events)) - var earliestStart time.Time - for i := range events { - rule, err := events[i].GetRRuleWithTimezone(qryCtx.Timezone) - if err != nil { - logrus.Errorf("event has invalid rule, event: %v", events[i]) - continue - } - parsed = append(parsed, parsedEvent{event: events[i], rule: rule}) - dtStart := rule.GetDTStart() - if earliestStart.IsZero() || dtStart.Before(earliestStart) { - earliestStart = dtStart - } - } - if len(parsed) == 0 { - return []ClassEventInstance{}, nil - } - - var startTime, untilTime time.Time - - if month == "" || year == "" { - if userId != nil { - var enrollment ProgramClassEnrollment - err := db.WithContext(qryCtx.Ctx). - Model(&ProgramClassEnrollment{}). - Where("cohort_id = ? AND user_id = ?", classId, *userId). - First(&enrollment).Error - if err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - startTime = earliestStart - if enrollment.CreatedAt.After(startTime) { - startTime = enrollment.CreatedAt.Truncate(24 * time.Hour) - } - untilTime = time.Now().AddDate(0, 0, 1).Truncate(24 * time.Hour) - if enrollment.EnrollmentStatus != "Enrolled" { - untilTime = enrollment.UpdatedAt.AddDate(0, 0, 1).Truncate(24 * time.Hour) - } - } else { - startTime = earliestStart - if allInstances { - // rrule-go's GetUntil() returns a far-future date (~year 2318) instead of zero - // when no UNTIL is present, so check the raw string for an explicit UNTIL clause. - untilTime = time.Now().AddDate(1, 0, 0).Truncate(24 * time.Hour) - var latestUntil time.Time - for _, pe := range parsed { - if strings.Contains(pe.event.RecurrenceRule, "UNTIL=") { - if u := pe.rule.GetUntil().AddDate(0, 0, 1); u.After(latestUntil) { - latestUntil = u - } - } - } - if !latestUntil.IsZero() { - untilTime = latestUntil - } - maxFuture := time.Now().AddDate(0, 6, 0).Truncate(24 * time.Hour) - if untilTime.After(maxFuture) { - untilTime = maxFuture - } - } else { - untilTime = time.Now().AddDate(0, 1, 0).Truncate(24 * time.Hour) - } - } - } else { - yearInt, err := strconv.Atoi(year) - if err != nil { - return nil, newDBError(err, "invalid year query parameter") - } - monthInt, err := strconv.Atoi(month) - if err != nil { - return nil, newDBError(err, "invalid month query parameter") - } - startTime = time.Date(yearInt, time.Month(monthInt), 1, 0, 0, 0, 0, loc) - untilTime = startTime.AddDate(0, 1, 0) - } - - // Fetch attendance across all event IDs so records on older (capped) rows still surface. - classEventIDs := make([]uint, 0, len(events)) - for i := range events { - classEventIDs = append(classEventIDs, events[i].ID) - } - var attendances []ProgramClassEventAttendance - startDateStr := startTime.Format("2006-01-02") - endDateStr := untilTime.Format("2006-01-02") - tx := db.WithContext(qryCtx.Ctx). - Model(&ProgramClassEventAttendance{}). - Where("event_id IN (?) AND date >= ? AND date < ?", classEventIDs, startDateStr, endDateStr) - if userId != nil { - tx = tx.Where("user_id = ?", *userId) - } - if err := tx. - Order("date DESC"). - Find(&attendances).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_event_attendances") - } - - var eventInstances []ClassEventInstance - for _, pe := range parsed { - occurrences := pe.rule.Between(startTime, untilTime, true) - classTime, canonicalHour, canonicalMinute := eventClassTime(pe.event, pe.rule, occurrences, loc) - eventInstances = append(eventInstances, createEventInstances(pe.event, occurrences, loc, classTime, attendances, canonicalHour, canonicalMinute)...) - } - eventInstances = dedupeClassEventInstances(eventInstances) - - qryCtx.Total = int64(len(eventInstances)) - if !qryCtx.All { - offset := qryCtx.CalcOffset() - end := offset + qryCtx.PerPage - if offset >= len(eventInstances) { - eventInstances = []ClassEventInstance{} - } else if end > len(eventInstances) { - eventInstances = eventInstances[offset:] - } else { - eventInstances = eventInstances[offset:end] - } - } - - return eventInstances, nil -} - -func createEventInstances(event ProgramClassEvent, occurrences []time.Time, loc *time.Location, classTime string, attendances []ProgramClassEventAttendance, canonicalHour, canonicalMinute int) []ClassEventInstance { - // Pre-index attendance by date for O(1) lookup instead of scanning all records per occurrence - attendanceByDate := make(map[string][]ProgramClassEventAttendance, len(attendances)) - for i := range attendances { - attendanceByDate[attendances[i].Date] = append(attendanceByDate[attendances[i].Date], attendances[i]) - } - - eventInstances := make([]ClassEventInstance, 0, len(occurrences)) - //daylight saving time fix (VIP) prevents the time shift - for _, occ := range occurrences { - occDateStr := occ.In(loc).Format("2006-01-02") - consistentOccurrence := time.Date( - occ.Year(), - occ.Month(), - occ.Day(), - canonicalHour, - canonicalMinute, - 0, - 0, - loc, - ) - isCancelled, isRescheduled, _, overrideID, _ := checkEventCancelledAndRescheduled(consistentOccurrence, event.Overrides, loc.String()) - - eventInstance := ClassEventInstance{ - EventID: event.ID, - ClassTime: classTime, - Date: occDateStr, - AttendanceRecords: attendanceByDate[occDateStr], - // event.IsCancelled is a series-level cancellation: every occurrence is cancelled. - IsCancelled: isCancelled || event.IsCancelled, - IsRescheduled: isRescheduled, - } - if isCancelled { - eventInstance.OverrideID = overrideID - } - if isCancelled && isRescheduled { - // Find the final reschedule target by following the chain. - // Simple case: non-cancelled override linked to this cancel (A→B). - // Chain case: cancelled override linked to this cancel (A→B→C), - // then follow B's cancel to find C's non-cancelled reschedule target. - currentID := overrideID - visited := map[uint]bool{currentID: true} - for { - foundTarget := false - // Look for non-cancelled reschedule target linked to currentID - for _, other := range event.Overrides { - if !other.IsCancelled && other.LinkedOverrideEventID != nil && *other.LinkedOverrideEventID == currentID { - rRule, err := rrule.StrToRRule(other.OverrideRrule) - if err == nil && len(rRule.All()) > 0 { - eventInstance.RescheduledToDate = rRule.All()[0].In(loc).Format("2006-01-02") - } - foundTarget = true - break - } - } - if foundTarget { - break - } - // No non-cancelled target found — look for a cancelled intermediate - // (chain case: B was re-rescheduled, so B's override is now cancelled) - nextID := uint(0) - for _, other := range event.Overrides { - if other.IsCancelled && other.LinkedOverrideEventID != nil && *other.LinkedOverrideEventID == currentID && !visited[other.ID] { - nextID = other.ID - break - } - } - if nextID == 0 { - break - } - visited[nextID] = true - currentID = nextID - } - } - eventInstances = append(eventInstances, eventInstance) - } - // Collect non-cancelled, non-reschedule overrides (room/instructor/time changes) - // grouped by date so we can match them to base instances by time - type sameDateOverride struct { - override ProgramClassEventOverride - startTime string - } - sameDateOverridesByDate := make(map[string][]sameDateOverride) - for _, override := range event.Overrides { - isReschedule := override.LinkedOverrideEventID != nil && *override.LinkedOverrideEventID != override.ID - if override.IsCancelled || isReschedule { - continue - } - parsedRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil || len(parsedRule.All()) == 0 { - continue - } - overrideDate := parsedRule.All()[0].In(loc) - dateStr := overrideDate.Format("2006-01-02") - startTime := overrideDate.Format("15:04") - sameDateOverridesByDate[dateStr] = append(sameDateOverridesByDate[dateStr], sameDateOverride{ - override: override, - startTime: startTime, - }) - } - // Apply room/instructor/time overrides to base instances, matching by time - for i := range eventInstances { - overrides, ok := sameDateOverridesByDate[eventInstances[i].Date] - if !ok { - continue - } - instStartTime := strings.Split(eventInstances[i].ClassTime, "-")[0] - for _, sdo := range overrides { - if sdo.startTime == instStartTime { - parsedRule, _ := rrule.StrToRRule(sdo.override.OverrideRrule) - overrideDate := parsedRule.All()[0] - duration, err := time.ParseDuration(sdo.override.Duration) - if err == nil { - overrideEnd := overrideDate.Add(duration) - eventInstances[i].ClassTime = overrideDate.In(loc).Format("15:04") + "-" + overrideEnd.In(loc).Format("15:04") - } - eventInstances[i].OverrideID = sdo.override.ID - break - } - } - } - // Index existing instances by date for dedup - instanceByDate := make(map[string]int, len(eventInstances)) - for i, inst := range eventInstances { - instanceByDate[inst.Date] = i - } - // Add rescheduled sessions (overrides with LinkedOverrideEventID pointing to a different override). - // Includes both non-cancelled targets (normal reschedule) and cancelled targets - // (Scenario 1: B was rescheduled from A, then B was cancelled — B is both a - // reschedule target and cancelled, needs to show as "Cancelled + Rescheduled Class"). - for _, override := range event.Overrides { - isReschedule := override.LinkedOverrideEventID != nil && *override.LinkedOverrideEventID != override.ID - if !isReschedule { - continue - } - parsedRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("unable to parse reschedule override rule: %s", override.OverrideRrule) - continue - } - allOccs := parsedRule.All() - if len(allOccs) == 0 { - logrus.Warnf("cancelled override rule does not contain a date instance, rule is: %s", override.OverrideRrule) - continue - } - overrideDate := allOccs[0] - overrideDateStr := overrideDate.In(loc).Format("2006-01-02") - - duration, err := time.ParseDuration(override.Duration) - if err != nil { - logrus.Errorf("error parsing duration for event: %v", err) - } - overrideEnd := overrideDate.Add(duration) - overrideClassTime := overrideDate.In(loc).Format("15:04") + "-" + overrideEnd.In(loc).Format("15:04") - - rescheduledFromDate := "" - for _, other := range event.Overrides { - if other.ID == *override.LinkedOverrideEventID && other.IsCancelled { - otherRule, err := rrule.StrToRRule(other.OverrideRrule) - if err == nil && len(otherRule.All()) > 0 { - rescheduledFromDate = otherRule.All()[0].In(loc).Format("2006-01-02") - } - break - } - } - rescheduledInstance := ClassEventInstance{ - EventID: event.ID, - ClassTime: overrideClassTime, - Date: overrideDateStr, - AttendanceRecords: attendanceByDate[overrideDateStr], - IsCancelled: override.IsCancelled, - RescheduledFromDate: rescheduledFromDate, - OverrideID: override.ID, - } - if existingIdx, exists := instanceByDate[overrideDateStr]; exists { - existingInst := eventInstances[existingIdx] - if existingInst.ClassTime == overrideClassTime { - // Same time slot on the same date — replace (same session with reschedule info) - eventInstances[existingIdx] = rescheduledInstance - } else { - // Different times on the same date — keep both as separate sessions - eventInstances = append(eventInstances, rescheduledInstance) - } - } else { - instanceByDate[overrideDateStr] = len(eventInstances) - eventInstances = append(eventInstances, rescheduledInstance) - } - } - slices.SortFunc(eventInstances, func(a, b ClassEventInstance) int { - return cmp.Compare(b.Date, a.Date) - }) - return eventInstances -} - -// eventClassTime returns the displayed "HH:MM-HH:MM" class time and canonical -// hour/minute, locked to the DTSTART's wall-clock time to avoid DST shifts. -func eventClassTime(event ProgramClassEvent, rRule *rrule.RRule, occurrences []time.Time, loc *time.Location) (string, int, int) { - if len(occurrences) == 0 { - return "", 0, 0 - } - duration, err := time.ParseDuration(event.Duration) - if err != nil { - logrus.Errorf("error parsing duration for event: %v", err) - } - dtStart := rRule.GetDTStart() - canonicalHour := dtStart.Hour() - canonicalMinute := dtStart.Minute() - - firstOcc := occurrences[0] - consistentOccurrence := time.Date( - firstOcc.Year(), firstOcc.Month(), firstOcc.Day(), - canonicalHour, canonicalMinute, 0, 0, loc, - ) - startTimeStr := consistentOccurrence.Format("15:04") - endTimeStr := consistentOccurrence.Add(duration).Format("15:04") - return startTimeStr + "-" + endTimeStr, canonicalHour, canonicalMinute -} - -// dedupeClassEventInstances merges instances from multiple rows. On a date+start-time -// collision the newest row wins (rows arrive oldest-first); sorted by date descending. -func dedupeClassEventInstances(instances []ClassEventInstance) []ClassEventInstance { - type instanceKey struct{ date, start string } - indexByKey := make(map[instanceKey]int, len(instances)) - result := make([]ClassEventInstance, 0, len(instances)) - for _, inst := range instances { - start := inst.ClassTime - if dash := strings.Index(start, "-"); dash != -1 { - start = start[:dash] - } - key := instanceKey{date: inst.Date, start: start} - if idx, ok := indexByKey[key]; ok { - result[idx] = inst - continue - } - indexByKey[key] = len(result) - result = append(result, inst) - } - slices.SortFunc(result, func(a, b ClassEventInstance) int { - return cmp.Compare(b.Date, a.Date) - }) - return result -} - -func (db *Store) GetCancelledOverrideEvents(qryCtx *QueryContext, eventId int) ([]ProgramClassEventOverride, error) { - overrides := make([]ProgramClassEventOverride, 0) - if err := db.WithContext(qryCtx.Ctx).Model(&ProgramClassEventOverride{}).Where("event_id = ? and is_cancelled = true", eventId).Find(&overrides).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_event_overrides") - } - return overrides, nil -} - -func (db *Store) GetClassEventDatesForRecurrence(classID int, timezone string, month, year string, eventId *int) ([]EventDates, error) { - // Load all rows so a multi-row class shows every date; honor a specific event_id if given. - var events []ProgramClassEvent - query := db.Preload("Overrides").Where("cohort_id = ?", classID) - if eventId != nil { - query = query.Where("id = ?", *eventId) - } - if err := query.Order("created_at ASC").Find(&events).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - if len(events) == 0 { - return nil, newGetRecordsDBError(gorm.ErrRecordNotFound, "program_class_events") - } - - loc, _ := time.LoadLocation(timezone) - y, _ := strconv.Atoi(year) - m, _ := strconv.Atoi(month) - start := time.Date(y, time.Month(m), 1, 0, 0, 0, 0, loc) - until := start.AddDate(0, 1, 0) - - out := make([]EventDates, 0) - for i := range events { - rule, err := events[i].GetRRuleWithTimezone(timezone) - if err != nil { - logrus.Errorf("event has invalid rule, event: %v", events[i]) - continue - } - out = append(out, eventDatesForRow(events[i], rule, start, until, loc)...) - } - - return dedupeEventDates(out), nil -} - -func eventDatesForRow(event ProgramClassEvent, rule *rrule.RRule, start, until time.Time, loc *time.Location) []EventDates { - // A fully cancelled series contributes no scheduled dates. - if event.IsCancelled { - return nil - } - occs := rule.Between(start, until, true) - - duration, err := time.ParseDuration(event.Duration) - if err != nil { - logrus.Errorf("error parsing duration for event: %v", err) - duration = time.Hour - } - - var canonicalHour, canonicalMinute int - if len(occs) > 0 { - dtstart := rule.GetDTStart() - canonicalHour = dtstart.Hour() - canonicalMinute = dtstart.Minute() - } - - out := make([]EventDates, 0, len(occs)) - for _, occ := range occs { - isCancelled, _, _, _, _ := checkEventCancelledAndRescheduled(occ, event.Overrides, loc.String()) - if isCancelled { - continue - } - occInLoc := occ.In(loc) - // lock the hour/minute to the canonical values to avoid DST shift :( - consistentStart := time.Date( - occInLoc.Year(), - occInLoc.Month(), - occInLoc.Day(), - canonicalHour, - canonicalMinute, - 0, - 0, - loc, - ) - startStr := consistentStart.Format("15:04") - endStr := consistentStart.Add(duration).Format("15:04") - out = append(out, EventDates{ - EventID: event.ID, - Date: consistentStart.Format("2006-01-02"), - ClassTime: startStr + "-" + endStr, - }) - } - - //checking the overrides for rescheduled dates (non-cancelled overrides are reschedule targets) - for _, override := range event.Overrides { - if override.IsCancelled { - continue - } - rRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil || len(rRule.All()) == 0 { - continue - } - overrideDate := rRule.All()[0].In(loc) - if overrideDate.Before(start) || !overrideDate.Before(until) { - continue - } - dur, err := time.ParseDuration(override.Duration) - if err != nil { - logrus.Errorf("error parsing override duration for event: %v", err) - dur = duration - } - classTime := overrideDate.Format("15:04") + "-" + overrideDate.Add(dur).Format("15:04") - out = append(out, EventDates{ - EventID: event.ID, - Date: overrideDate.Format("2006-01-02"), - ClassTime: classTime, - }) - } - - return out -} - -// dedupeEventDates drops entries sharing a date and class time; newest row wins on a tie. -func dedupeEventDates(dates []EventDates) []EventDates { - type dateKey struct{ date, classTime string } - indexByKey := make(map[dateKey]int, len(dates)) - result := make([]EventDates, 0, len(dates)) - for _, d := range dates { - key := dateKey{date: d.Date, classTime: d.ClassTime} - if idx, ok := indexByKey[key]; ok { - result[idx] = d - continue - } - indexByKey[key] = len(result) - result = append(result, d) - } - return result -} - -func (db *Store) GetProgramClassEventOverrides(qryCtx *QueryContext, eventIDs ...uint) ([]ProgramClassEventOverride, error) { - overrides := make([]ProgramClassEventOverride, 0) - if err := db.WithContext(qryCtx.Ctx).Model(&ProgramClassEventOverride{}).Preload("RoomRef").Preload("Instructor").Where("event_id IN (?)", eventIDs).Order("id desc").Find(&overrides).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_event_overrides") - } - return overrides, nil -} - -func (db *Store) UpdateClassEventRRuleUntilDate(tx *gorm.DB, classIDs []int, completionTime time.Time) error { - if len(classIDs) == 0 { - return newDBError(errors.New("no class IDs provided"), "no class IDs provided") - } - - var events []struct { - ID uint `json:"id"` - RecurrenceRule string `json:"recurrence_rule"` - } - - if err := tx.Model(&ProgramClassEvent{}).Select("id, recurrence_rule").Where("cohort_id IN (?)", classIDs).Find(&events).Error; err != nil { - return newGetRecordsDBError(err, "program_class_events") - } - - if len(events) == 0 { - return newDBError(errors.New("no events found for the provided class IDs"), "no events found for the provided class IDs") - } - - untilDateStr := FormatDateForUntil(completionTime) - updates := make(map[uint]string, len(events)) - eventIDs := make([]uint, len(events)) - for i, event := range events { - eventIDs[i] = event.ID - updates[event.ID] = ReplaceOrAddUntilDate(event.RecurrenceRule, untilDateStr) - } - - caseParts := make([]string, 0, len(updates)) - args := make([]any, 0, len(updates)*2) - - for eventID, newRRule := range updates { - caseParts = append(caseParts, "WHEN id = ? THEN ?") - args = append(args, eventID, newRRule) - } - - caseSQL := "CASE " + strings.Join(caseParts, " ") + " END" - if err := tx.Model(&ProgramClassEvent{}).Where("id IN (?)", eventIDs).Update("recurrence_rule", gorm.Expr(caseSQL, args...)).Error; err != nil { - return newUpdateDBError(err, "program_class_events") - } - return nil -} - -func getRoomName(roomRef *Room) string { - if roomRef != nil { - return roomRef.Name - } - return "TBD" -} diff --git a/backend/src/modules/programs/classes_handler.go b/backend/src/modules/programs/classes_handler.go deleted file mode 100644 index ca584ef41..000000000 --- a/backend/src/modules/programs/classes_handler.go +++ /dev/null @@ -1,584 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "slices" - "strconv" - - "github.com/UnlockedLabs/unlocked-contracts/features" - "github.com/UnlockedLabs/unlocked-contracts/identity" - "github.com/UnlockedLabs/unlocked-contracts/users" - logrus "github.com/sirupsen/logrus" -) - -func (srv *server) handleGetClassesForProgram(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - if uint(id) >= CanvasProgramIDOffset { - return srv.handleGetCanvasClasses(w, r, log, uint(id)) - } - args := getQueryContext(r) - service := NewClassesService(srv.store) - classes, err := service.GetProgramClassDetailsForProgram(&args, id) - if err != nil { - log.add("program_id", id) - return newDatabaseServiceError(err) - } - return writePaginatedResponse(w, http.StatusOK, classes, args.IntoMeta()) -} - -func (srv *server) handleGetClass(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - if uint(id) >= CanvasClassIDOffset { - return srv.handleGetCanvasClassDetail(w, r, log, uint(id)) - } - class, err := srv.store.GetCohortByID(id) - if err != nil { - log.add("class_id", id) - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, class) -} - -func (srv *server) handleIndexClassesForFacility(w http.ResponseWriter, r *http.Request, log sLog) error { - args := getQueryContext(r) - claims := claimsFrom(r) - facilityParam := r.URL.Query().Get("facility") - var facilityID *uint - if facilityParam == "all" && claims.CanSwitchFacility() { - facilityID = nil - } else { - facilityID = &args.FacilityID - } - classes, err := srv.store.GetClasses(&args, facilityID) - if err != nil { - return newDatabaseServiceError(err) - } - if claims.HasFeature(features.ProviderPlatforms) { - // Provider classes are read over HTTP, which is far slower than our own - // tables, so they are served from cache and refreshed in the background. - // Our classes go out now; the provider's arrive on a later poll. - canvasClasses, loading := srv.getCanvasProviderClasses(facilityID) - canvasClasses = srv.filterClassesByProviderAccess(r.Context(), canvasClasses) - classes = append(classes, canvasClasses...) - args.Total += int64(len(canvasClasses)) - if loading { - placeholders := srv.loadingClassPlaceholders(facilityID) - classes = append(classes, placeholders...) - args.Total += int64(len(placeholders)) - } - } - return writePaginatedResponse(w, http.StatusOK, classes, args.IntoMeta()) -} - -// filterClassesByProviderAccess drops Canvas classes belonging to a facility that has -// disabled provider_platforms via its per-facility override. The caller-level -// hasFeatureAccess check only reflects the requesting user's own facility, which isn't -// sufficient when classes span every facility (facility=all). -func (srv *server) filterClassesByProviderAccess(ctx context.Context, classes []ProgramClassCohort) []ProgramClassCohort { - access := make(map[uint]bool) - filtered := make([]ProgramClassCohort, 0, len(classes)) - for _, class := range classes { - enabled, resolved := access[class.FacilityID] - if !resolved { - effective, err := srv.facilityFeatures.FacilityFeatures(ctx, class.FacilityID) - if err != nil { - logrus.WithError(err).WithField("facility_id", class.FacilityID).Warn("failed to resolve facility feature access, excluding canvas classes") - enabled = false - } else { - enabled = slices.Contains(effective, features.ProviderPlatforms) - } - access[class.FacilityID] = enabled - } - if enabled { - filtered = append(filtered, class) - } - } - return filtered -} - -func (srv *server) handleCreateClass(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("program_id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - return newJSONReqBodyServiceError(err) - } - - var class ProgramClassCohort - err = json.Unmarshal(bodyBytes, &class) - if err != nil { - return newJSONReqBodyServiceError(err) - } - - facilityID, err := requireFacilityID(r) - if err != nil { - return err - } - class.FacilityID = facilityID - class.ProgramID = uint(id) - - if class.InstructorID == nil || *class.InstructorID == 0 { - return newBadRequestServiceError(nil, "instructor selection is required") - } - - instructorName, err := srv.store.GetInstructorNameByID(*class.InstructorID, class.FacilityID) - if err != nil || instructorName == "" { - return newBadRequestServiceError(err, "invalid instructor selected") - } - for i := range class.Events { - class.Events[i].InstructorID = class.InstructorID - } - - var conflictReq *ConflictCheckRequest - if len(class.Events) > 0 && class.Events[0].RecurrenceRule != "" && class.Events[0].Duration != "" { - event := class.Events[0] - if event.RoomID != nil { - if _, err := srv.store.GetRoomByIDForFacility(*event.RoomID, class.FacilityID); err != nil { - return newDatabaseServiceError(err) - } - } - conflictReq = &ConflictCheckRequest{ - FacilityID: class.FacilityID, - RecurrenceRule: event.RecurrenceRule, - Duration: event.Duration, - } - if event.RoomID != nil { - conflictReq.RoomID = *event.RoomID - } - if class.InstructorID != nil { - conflictReq.InstructorID = *class.InstructorID - } - } - - newClass, conflicts, err := srv.WithUserContext(r).CreateProgramClass(&class, conflictReq) - if err != nil { - return newDatabaseServiceError(err) - } - if len(conflicts) > 0 { - return writeConflictResponse(w, conflicts) - } - log.add("program_id", id) - log.add("class_id", newClass.ID) - return writeJsonResponse(w, http.StatusCreated, newClass) -} - -func (srv *server) handleUpdateClass(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - return newJSONReqBodyServiceError(err) - } - - class := ProgramClassCohort{} - err = json.Unmarshal(bodyBytes, &class) - if err != nil { - return newJSONReqBodyServiceError(err) - } - - claims := claimsFrom(r) - - var rawClass map[string]any - var instructorIDProvided bool - if err := json.Unmarshal(bodyBytes, &rawClass); err == nil { - if _, exists := rawClass["instructor_id"]; exists { - instructorIDProvided = true - } - } - - existing, err := srv.store.GetCohortByID(id) - if err != nil { - return newDatabaseServiceError(err) - } - if existing.CannotUpdateClass() { - return newBadRequestServiceError(err, "cannot perform action on class that is completed cancelled or archived") - } - - if instructorIDProvided { - if class.InstructorID == nil || *class.InstructorID == 0 { - class.InstructorID = nil - } else { - instructorName, err := srv.store.GetInstructorNameByID(*class.InstructorID, existing.FacilityID) - if err != nil || instructorName == "" { - return newBadRequestServiceError(err, "invalid instructor selected") - } - } - class.UpdateInstructor = true - } - enrolled, err := srv.store.GetTotalEnrollmentsByClassID(id) - if err != nil { - return newDatabaseServiceError(err) - } - if enrolled > class.Capacity { - return writeJsonResponse(w, http.StatusBadRequest, "Cannot update class until unenrolling residents") - } - class.UpdateUserID = UintPtr(claims.UserID) - - var conflictReq *ConflictCheckRequest - if len(existing.Events) > 0 { - existingEvent := existing.Events[0] - - var roomChanged bool - if len(class.Events) > 0 && class.Events[0].RoomID != nil { - if _, err := srv.store.GetRoomByIDForFacility(*class.Events[0].RoomID, existing.FacilityID); err != nil { - return newDatabaseServiceError(err) - } - existingRoomID := uint(0) - if existingEvent.RoomID != nil { - existingRoomID = *existingEvent.RoomID - } - if *class.Events[0].RoomID != existingRoomID { - roomChanged = true - } - } - - var instructorChanged bool - if instructorIDProvided && class.InstructorID != nil { - existingInstructorID := uint(0) - if existingEvent.InstructorID != nil { - existingInstructorID = *existingEvent.InstructorID - } - if *class.InstructorID != existingInstructorID { - instructorChanged = true - } - } - - if (roomChanged || instructorChanged) && existingEvent.RecurrenceRule != "" && existingEvent.Duration != "" { - conflictReq = &ConflictCheckRequest{ - FacilityID: existing.FacilityID, - RecurrenceRule: existingEvent.RecurrenceRule, - Duration: existingEvent.Duration, - ExcludeEventID: &existingEvent.ID, - ExcludeClassID: &existing.ID, - } - if roomChanged { - conflictReq.RoomID = *class.Events[0].RoomID - } - if instructorChanged { - conflictReq.InstructorID = *class.InstructorID - } - } - } - - updated, conflicts, err := srv.WithUserContext(r).UpdateProgramClass(&class, id, conflictReq) - if err != nil { - return newDatabaseServiceError(err) - } - if len(conflicts) > 0 { - return writeConflictResponse(w, conflicts) - } - return writeJsonResponse(w, http.StatusOK, updated) -} - -func (srv *server) handleUpdateClasses(w http.ResponseWriter, r *http.Request, log sLog) error { - ids := r.URL.Query()["id"] - classIDs := make([]int, 0, len(ids)) - for _, id := range ids { - classID, err := strconv.Atoi(id) - if err != nil { - log.warn("invalid class ID in batch update, skipping: ", id) - continue - } - classIDs = append(classIDs, classID) - } - classMap := make(map[string]any) - if err := json.NewDecoder(r.Body).Decode(&classMap); err != nil { - return newJSONReqBodyServiceError(err) - } - - claims := claimsFrom(r) - classMap["update_user_id"] = claims.UserID - - if err := srv.WithUserContext(r).UpdateProgramCohorts(classIDs, classMap); err != nil { - return newDatabaseServiceError(err) - } - - return writeJsonResponse(w, http.StatusOK, "Successfully updated program class") -} - -func (srv *server) handleGetClassHistory(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - if uint(id) >= CanvasClassIDOffset { - args := getQueryContext(r) - return writePaginatedResponse(w, http.StatusOK, - []ChangeLogEntry{}, - newPaginationInfo(1, args.PerPage, 0)) - } - args := getQueryContext(r) - categories := r.URL.Query()["categories"] - historyEvents, err := srv.store.GetChangeLogEntries(&args, TableNameCohort, id, categories) - if err != nil { - return err - } - pageMeta, createdByDetails, err := srv.getCreatedByForHistory(id, TableNameCohort, args.IntoMeta(), &args, len(historyEvents), categories) - if err != nil { - return newDatabaseServiceError(err) - } - if createdByDetails.Action != "" { - historyEvents = append(historyEvents, createdByDetails) - } - return writePaginatedResponse(w, http.StatusOK, historyEvents, pageMeta) -} - -func (srv *server) handleGetAttendanceFlagsForClass(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - if uint(id) >= CanvasClassIDOffset { - return srv.handleGetCanvasAtRiskStudents(w, r, uint(id)) - } - args := getQueryContext(r) - flags, err := srv.store.GetAttendanceFlagsForClass(id, &args) - if err != nil { - return newDatabaseServiceError(err) - } - return writePaginatedResponse(w, http.StatusOK, flags, args.IntoMeta()) -} - -func (srv *server) handleGetProgramClassOutcomes(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("program_id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - args := getQueryContext(r) - outcome, err := srv.store.GetProgramClassOutcomes(id, &args) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, outcome) -} - -func (srv *server) handleGetMissingAttendance(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - args := getQueryContext(r) - totalMissing, err := srv.store.GetMissingAttendance(id, &args) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, totalMissing) -} - -func (srv *server) handleGetMissingAttendanceForFacility(w http.ResponseWriter, r *http.Request, log sLog) error { - args := getQueryContext(r) - facility := r.URL.Query().Get("facility") - var facilityId *uint - switch facility { - case "all": - facilityId = nil - case "": - facilityId = &args.FacilityID - default: - facilityId = &args.FacilityID - } - - days := 3 - if daysQuery := r.URL.Query().Get("days"); daysQuery != "" { - if parsedDays, err := strconv.Atoi(daysQuery); err == nil { - days = parsedDays - } - } - - service := NewClassesService(srv.store) - items, err := service.GetMissingAttendanceForFacility(&args, facilityId, days) - if err != nil { - return newDatabaseServiceError(err) - } - - args.Total = int64(len(items)) - if !args.All { - start := args.CalcOffset() - if start < len(items) { - end := start + args.PerPage - if end > len(items) { - end = len(items) - } - items = items[start:end] - } else { - items = []MissingAttendanceItem{} - } - } - - return writePaginatedResponse(w, http.StatusOK, items, args.IntoMeta()) -} - -func (srv *server) handleGetCumulativeAttendanceRate(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - if uint(classID) >= CanvasClassIDOffset { - return writeJsonResponse(w, http.StatusOK, map[string]*float64{"attendance_rate": nil}) - } - attendanceRate, err := srv.store.GetCumulativeAttendanceRateForClass(r.Context(), classID) - if err != nil { - return newDatabaseServiceError(err) - } - response := map[string]*float64{ - "attendance_rate": attendanceRate, - } - return writeJsonResponse(w, http.StatusOK, response) -} - -func (srv *server) handleGetClassesByInstructor(w http.ResponseWriter, r *http.Request, log sLog) error { - instructorId, err := strconv.Atoi(r.PathValue("id")) - if err != nil || instructorId < 0 { - return newInvalidIdServiceError(fmt.Errorf("instructor ID must be 0 or positive"), "instructor ID") - } - - claims := claimsFrom(r) - - startDate := r.URL.Query().Get("start_date") - endDate := r.URL.Query().Get("end_date") - - if startDate == "" || endDate == "" { - return newBadRequestServiceError(nil, "start_date and end_date are required") - } - - classes, err := srv.store.GetClassesByInstructor(instructorId, int(claims.FacilityID), startDate, endDate) - if err != nil { - return newDatabaseServiceError(err) - } - - return writeJsonResponse(w, http.StatusOK, classes) -} - -func (srv *server) handleBulkCancelSessions(w http.ResponseWriter, r *http.Request, log sLog) error { - var req BulkCancelSessionsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return newJSONReqBodyServiceError(err) - } - - claims := claimsFrom(r) - - // Instructors live on program_class_events, not on the cohort (id751 moved the - // column), so validate against the same source the instructor picker draws from: - // facility users with an instructor role. InstructorID 0 is the picker's - // "Unassigned" option, which BulkCancelSessions handles as "events with no - // instructor" -- there is no user row to check for it. - if req.InstructorID != 0 { - var count int64 - err := srv.store.WithContext(r.Context()). - Table("users"). - Where("id = ? AND facility_id = ? AND role IN ? AND deactivated_at IS NULL AND deleted_at IS NULL", - req.InstructorID, claims.FacilityID, - []users.Role{users.FacilityAdmin, users.DepartmentAdmin}). - Count(&count).Error - if err != nil { - return newDatabaseServiceError(err) - } - if count == 0 { - return newBadRequestServiceError(nil, - "instructor not found or does not belong to your facility") - } - } - - claimsAdapter := &BulkCancelClaimsAdapter{Claims: claims} - - response, err := srv.store.BulkCancelSessions(&req, int(claims.FacilityID), claimsAdapter) - if err != nil { - return newDatabaseServiceError(err) - } - - log.add("instructor_id", req.InstructorID) - log.add("start_date", req.StartDate) - log.add("end_date", req.EndDate) - log.add("session_count", response.SessionCount) - log.add("class_count", response.ClassCount) - - return writeJsonResponse(w, http.StatusOK, response) -} - -type BulkCancelClaimsAdapter struct { - *identity.Claims -} - -func (c *BulkCancelClaimsAdapter) GetUserID() uint { - return c.UserID -} - -func (c *BulkCancelClaimsAdapter) GetFacilityID() uint { - return c.FacilityID -} - -func (c *BulkCancelClaimsAdapter) GetTimezone() string { - return c.TimeZone -} - -func (srv *server) handleGetClassDeleteCheck(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - log.add("class_id", id) - if uint(id) >= CanvasClassIDOffset { - payload := struct { - CanDelete bool `json:"can_delete"` - Blockers DeleteBlockingChildren `json:"blockers"` - }{ - CanDelete: false, - Blockers: DeleteBlockingChildren{NonDeletableStatus: "Canvas"}, - } - return writeJsonResponse(w, http.StatusOK, payload) - } - blockers, err := srv.store.ClassBlockingChildren(id) - if err != nil { - return newDatabaseServiceError(err) - } - payload := struct { - CanDelete bool `json:"can_delete"` - Blockers DeleteBlockingChildren `json:"blockers"` - }{ - CanDelete: !blockers.HasAny(), - Blockers: blockers, - } - return writeJsonResponse(w, http.StatusOK, payload) -} - -func (srv *server) handleDeleteClass(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - log.add("class_id", id) - if uint(id) >= CanvasClassIDOffset { - return newBadRequestServiceError(fmt.Errorf("canvas classes cannot be deleted"), "class ID") - } - - blockers, err := srv.store.ClassBlockingChildren(id) - if err != nil { - return newDatabaseServiceError(err) - } - if blockers.HasAny() { - log.info("class delete blocked by child records") - return writeDeleteConflictResponse(w, "cannot delete: class has child records", blockers) - } - - if err := srv.store.DeleteClass(id); err != nil { - return newDatabaseServiceError(err) - } - log.info("class deleted") - return writeJsonResponse(w, http.StatusNoContent, "Class deleted successfully") -} diff --git a/backend/src/modules/programs/classes_service.go b/backend/src/modules/programs/classes_service.go deleted file mode 100644 index 58105d060..000000000 --- a/backend/src/modules/programs/classes_service.go +++ /dev/null @@ -1,513 +0,0 @@ -package programs - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/teambition/rrule-go" -) - -type ClassesService struct { - db *Store -} - -func NewClassesService(db *Store) *ClassesService { - return &ClassesService{db: db} -} - -func (svc *ClassesService) GetFacilityHealthSummaries(args *QueryContext, facilityID *uint, days int) ([]FacilityHealthSummary, error) { - summaries, err := svc.db.GetFacilityHealthSummaries(args, facilityID) - if err != nil { - return nil, err - } - - attendanceConcerns, err := svc.db.GetFacilityAttendanceConcerns(args, facilityID) - if err != nil { - return nil, err - } - - items, classByID, err := svc.getMissingAttendanceForFacilityData(args, facilityID, days) - if err != nil { - return nil, err - } - - missingByFacilityClasses := make(map[uint]map[uint]struct{}) - for _, item := range items { - classInfo, ok := classByID[item.CohortID] - if !ok { - continue - } - if _, exists := missingByFacilityClasses[classInfo.FacilityID]; !exists { - missingByFacilityClasses[classInfo.FacilityID] = make(map[uint]struct{}) - } - missingByFacilityClasses[classInfo.FacilityID][item.CohortID] = struct{}{} - } - missingByFacility := make(map[uint]int64, len(missingByFacilityClasses)) - for facilityRef, classIDs := range missingByFacilityClasses { - missingByFacility[facilityRef] = int64(len(classIDs)) - } - - for i := range summaries { - facilityRef := summaries[i].FacilityID - summaries[i].AttendanceConcerns = attendanceConcerns[facilityRef] - summaries[i].MissingAttendance = missingByFacility[facilityRef] - } - - return summaries, nil -} - -func (svc *ClassesService) GetMissingAttendanceForFacility(args *QueryContext, facilityID *uint, days int) ([]MissingAttendanceItem, error) { - items, _, err := svc.getMissingAttendanceForFacilityData(args, facilityID, days) - if err != nil { - return nil, err - } - return items, nil -} - -func (svc *ClassesService) getMissingAttendanceForFacilityData(args *QueryContext, facilityID *uint, days int) ([]MissingAttendanceItem, map[uint]MissingAttendanceClass, error) { - if days <= 0 { - days = 3 - } - classes, err := svc.db.GetActiveClassesForMissingAttendance(args, facilityID) - if err != nil { - return nil, nil, err - } - if len(classes) == 0 { - return []MissingAttendanceItem{}, map[uint]MissingAttendanceClass{}, nil - } - - loc, err := time.LoadLocation(args.Timezone) - if err != nil { - loc = time.UTC - } - nowLocal := time.Now().In(loc) - startDate := time.Now().In(loc).AddDate(0, 0, -days).Truncate(24 * time.Hour) - endDate := time.Now().In(loc).AddDate(0, 0, 1).Truncate(24 * time.Hour) - - classIDs := make([]uint, 0, len(classes)) - classByID := make(map[uint]MissingAttendanceClass, len(classes)) - for _, cls := range classes { - classIDs = append(classIDs, cls.ID) - classByID[cls.ID] = cls - } - - events, err := svc.db.GetClassEventsWithOverrides(args, classIDs) - if err != nil { - return nil, nil, err - } - - type eventInstance struct { - EventInstance - DateKey string - } - instances := make([]eventInstance, 0) - eventIDSet := make(map[uint]struct{}) - dateSet := make(map[string]struct{}) - - for _, event := range events { - rule, err := event.GetRRuleWithTimezone(args.Timezone) - if err != nil { - continue - } - firstOccurrence := rule.After(time.Time{}, false) - canonicalHour, canonicalMinute := getCanonicalHourAndMinute([]time.Time{firstOccurrence}, args.Timezone) - eventInstances := GenerateEventInstances(event, startDate, endDate) - for _, inst := range eventInstances { - if inst.IsCancelled { - continue - } - localStart := inst.StartTime.In(loc) - consistentOccurrence := time.Date( - localStart.Year(), - localStart.Month(), - localStart.Day(), - canonicalHour, - canonicalMinute, - 0, - 0, - loc, - ).UTC() - if consistentOccurrence.After(nowLocal) { - continue - } - inst.StartTime = consistentOccurrence - dateKey := consistentOccurrence.In(loc).Format("2006-01-02") - instances = append(instances, eventInstance{ - EventInstance: inst, - DateKey: dateKey, - }) - eventIDSet[inst.EventID] = struct{}{} - dateSet[dateKey] = struct{}{} - } - } - if len(instances) == 0 { - return []MissingAttendanceItem{}, classByID, nil - } - - eventIDs := make([]uint, 0, len(eventIDSet)) - for id := range eventIDSet { - eventIDs = append(eventIDs, id) - } - dates := make([]string, 0, len(dateSet)) - for date := range dateSet { - dates = append(dates, date) - } - - attendanceCounts, err := svc.db.GetAttendanceCountsForEvents(args, eventIDs, dates) - if err != nil { - return nil, nil, err - } - attendanceMap := make(map[string]int64, len(attendanceCounts)) - for _, attendance := range attendanceCounts { - key := fmt.Sprintf("%d_%s", attendance.EventID, attendance.Date) - attendanceMap[key] = attendance.Count - } - - type enrollmentWindow struct { - EnrolledAt *time.Time - EnrollmentEndedAt *time.Time - } - enrollments, err := svc.db.GetActiveEnrollmentsForClasses(args, classIDs) - if err != nil { - return nil, nil, err - } - enrollmentsByCohort := make(map[uint][]enrollmentWindow, len(classIDs)) - for _, enrollment := range enrollments { - enrollmentsByCohort[enrollment.CohortID] = append(enrollmentsByCohort[enrollment.CohortID], enrollmentWindow{ - EnrolledAt: enrollment.EnrolledAt, - EnrollmentEndedAt: enrollment.EnrollmentEndedAt, - }) - } - - items := make([]MissingAttendanceItem, 0) - for _, inst := range instances { - enrolledCountOnDate := 0 - for _, enrollment := range enrollmentsByCohort[inst.CohortID] { - if enrollment.EnrolledAt == nil { - continue - } - if !enrollment.EnrolledAt.After(inst.StartTime) && - (enrollment.EnrollmentEndedAt == nil || enrollment.EnrollmentEndedAt.After(inst.StartTime.AddDate(0, 0, -1))) { - enrolledCountOnDate++ - } - } - if enrolledCountOnDate == 0 { - continue - } - key := fmt.Sprintf("%d_%s", inst.EventID, inst.DateKey) - attendanceCount := attendanceMap[key] - if int(attendanceCount) == enrolledCountOnDate { - continue - } - classInfo := classByID[inst.CohortID] - items = append(items, MissingAttendanceItem{ - CohortID: inst.CohortID, - ClassName: classInfo.Name, - FacilityName: classInfo.FacilityName, - EventID: inst.EventID, - Date: inst.DateKey, - StartTime: inst.StartTime.In(loc).Format("15:04"), - }) - } - - sort.Slice(items, func(i, j int) bool { - if items[i].Date == items[j].Date { - return items[i].StartTime > items[j].StartTime - } - return items[i].Date > items[j].Date - }) - - return items, classByID, nil -} - -func (svc *ClassesService) GetTodaysSchedule(args *QueryContext, facilityID *uint) ([]TodaysScheduleItem, error) { - classes, err := svc.db.GetActiveClassesWithEvents(args, facilityID) - if err != nil { - return nil, err - } - if len(classes) == 0 { - return []TodaysScheduleItem{}, nil - } - - loc, err := time.LoadLocation(args.Timezone) - if err != nil { - loc = time.UTC - } - nowLocal := time.Now().In(loc) - startOfToday := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 0, 0, 0, 0, loc) - endOfToday := startOfToday.AddDate(0, 0, 1) - - items := make([]TodaysScheduleItem, 0) - for _, class := range classes { - startDt := class.StartDt.In(loc) - if startDt.After(endOfToday) { - continue - } - if class.EndDt != nil { - endDt := class.EndDt.In(loc) - if endDt.Before(startOfToday) { - continue - } - } - - for _, event := range class.Events { - rule, err := event.GetRRuleWithTimezone(args.Timezone) - if err != nil { - continue - } - firstOccurrence := rule.After(time.Time{}, false) - canonicalHour, canonicalMinute := getCanonicalHourAndMinute([]time.Time{firstOccurrence}, args.Timezone) - overrideStartTimes := make(map[string]struct{}) - for _, override := range event.Overrides { - if override.IsCancelled { - continue - } - overrideOptions, err := rrule.StrToROption(override.OverrideRrule) - if err != nil { - continue - } - overrideOptions.Dtstart = overrideOptions.Dtstart.In(time.UTC) - overrideRule, err := rrule.NewRRule(*overrideOptions) - if err != nil { - continue - } - overrideOccurrences := overrideRule.Between(startOfToday.UTC(), endOfToday.UTC(), true) - for _, occ := range overrideOccurrences { - overrideStartTimes[occ.UTC().Format(time.RFC3339Nano)] = struct{}{} - } - } - eventInstances := GenerateEventInstances(event, startOfToday, endOfToday) - for _, inst := range eventInstances { - if inst.IsCancelled { - continue - } - localStart := inst.StartTime.In(loc) - localOcc := localStart - if _, isOverride := overrideStartTimes[inst.StartTime.UTC().Format(time.RFC3339Nano)]; !isOverride { - localOcc = time.Date( - localStart.Year(), - localStart.Month(), - localStart.Day(), - canonicalHour, - canonicalMinute, - 0, - 0, - loc, - ) - } - if localOcc.Before(startOfToday) || !localOcc.Before(endOfToday) { - continue - } - facilityName := "" - if class.Facility != nil { - facilityName = class.Facility.Name - } - _, instructorName := GetInstructorFromEvents(class.Events) - items = append(items, TodaysScheduleItem{ - CohortID: class.ID, - ClassName: class.ClassName, - InstructorName: instructorName, - FacilityID: class.FacilityID, - FacilityName: facilityName, - EventID: inst.EventID, - Date: localOcc.Format("2006-01-02"), - StartTime: localOcc.Format("15:04"), - Room: inst.Room, - }) - } - } - } - - if len(items) > 0 { - eventIDs := make([]uint, 0, len(items)) - dates := make([]string, 0, len(items)) - for _, item := range items { - eventIDs = append(eventIDs, item.EventID) - dates = append(dates, item.Date) - } - attendanceCounts, err := svc.db.GetAttendanceCountsForEvents(args, eventIDs, dates) - if err == nil { - taken := make(map[string]bool) - for _, ac := range attendanceCounts { - taken[fmt.Sprintf("%d|%s", ac.EventID, ac.Date)] = true - } - for i, item := range items { - items[i].HasAttendance = taken[fmt.Sprintf("%d|%s", item.EventID, item.Date)] - } - } - - classIDSet := make(map[uint]struct{}, len(items)) - for _, item := range items { - classIDSet[item.CohortID] = struct{}{} - } - classIDs := make([]uint, 0, len(classIDSet)) - for id := range classIDSet { - classIDs = append(classIDs, id) - } - enrollmentCounts, err := svc.db.GetActiveEnrollmentCountsForClasses(args, classIDs) - if err == nil { - for i := range items { - items[i].EnrolledCount = enrollmentCounts[items[i].CohortID] - } - } - } - - sort.Slice(items, func(i, j int) bool { - if items[i].Date == items[j].Date { //sorting by date, time, then by name - if items[i].StartTime == items[j].StartTime { - return items[i].ClassName < items[j].ClassName - } - return items[i].StartTime < items[j].StartTime - } - return items[i].Date < items[j].Date - }) - - return items, nil -} - -func (svc *ClassesService) GetProgramClassDetailsForProgram(args *QueryContext, programID int) ([]ProgramClassDetail, error) { - classes, err := svc.db.GetProgramClassDetailsByID(programID, args) - if err != nil { - return nil, err - } - if len(classes) == 0 { - return classes, nil - } - - classIDs := make([]uint, 0, len(classes)) - for _, cls := range classes { - classIDs = append(classIDs, cls.ID) - } - attendanceByClass, err := svc.db.GetCumulativeAttendanceRatesForClasses(args.Ctx, classIDs) - if err != nil { - return nil, err - } - - for i := range classes { - schedule, room := formatClassScheduleAndRoom(classes[i].Events, args.Timezone) - classes[i].Schedule = schedule - if room != "" { - classes[i].Room = room - } - if rate, ok := attendanceByClass[classes[i].ID]; ok { - classes[i].AttendanceRate = &rate - } - } - - return classes, nil -} - -func FormatClassScheduleAndRoom(events []ProgramClassEvent, timezone string) (string, string) { - return formatClassScheduleAndRoom(events, timezone) -} - -func formatClassScheduleAndRoom(events []ProgramClassEvent, timezone string) (string, string) { - var event *ProgramClassEvent - if len(events) > 0 { - event = &events[0] - } - if event == nil { - return "", "" - } - - roomName := "" - if event.RoomRef != nil { - roomName = event.RoomRef.Name - } - - rule, err := event.GetRRuleWithTimezone(timezone) - if err != nil { - return "", roomName - } - opts := rule.Options - if opts.Dtstart.IsZero() { - return "", roomName - } - - loc := time.UTC - if timezone != "" { - if tz, err := time.LoadLocation(timezone); err == nil { - loc = tz - } - } - - startTime := opts.Dtstart.In(loc) - duration, err := time.ParseDuration(event.Duration) - if err != nil { - return "", roomName - } - endTime := startTime.Add(duration) - - days := formatWeekdays(opts.Byweekday, startTime.Weekday()) - timeRange := fmt.Sprintf("%s - %s", startTime.Format("3:04 PM"), endTime.Format("3:04 PM")) - if len(days) == 0 { - return timeRange, roomName - } - return fmt.Sprintf("%s • %s", strings.Join(days, ", "), timeRange), roomName -} - -func formatWeekdays(days []rrule.Weekday, fallback time.Weekday) []string { - if len(days) == 0 { - return []string{fullWeekdayName(fallback)} - } - unique := make(map[int]rrule.Weekday, len(days)) - for _, day := range days { - unique[day.Day()] = day - } - ordered := make([]rrule.Weekday, 0, len(unique)) - for _, day := range unique { - ordered = append(ordered, day) - } - sort.Slice(ordered, func(i, j int) bool { - return ordered[i].Day() < ordered[j].Day() - }) - names := make([]string, 0, len(ordered)) - for _, day := range ordered { - names = append(names, fullWeekdayNameFromRRule(day.Day())) - } - return names -} - -func fullWeekdayName(day time.Weekday) string { - switch day { - case time.Monday: - return "Monday" - case time.Tuesday: - return "Tuesday" - case time.Wednesday: - return "Wednesday" - case time.Thursday: - return "Thursday" - case time.Friday: - return "Friday" - case time.Saturday: - return "Saturday" - case time.Sunday: - return "Sunday" - default: - return "" - } -} - -func fullWeekdayNameFromRRule(day int) string { - switch day { - case 0: - return "Monday" - case 1: - return "Tuesday" - case 2: - return "Wednesday" - case 3: - return "Thursday" - case 4: - return "Friday" - case 5: - return "Saturday" - case 6: - return "Sunday" - default: - return "" - } -} diff --git a/backend/src/modules/programs/dashboard_store.go b/backend/src/modules/programs/dashboard_store.go deleted file mode 100644 index ed36a2b79..000000000 --- a/backend/src/modules/programs/dashboard_store.go +++ /dev/null @@ -1,191 +0,0 @@ -package programs - -func (db *Store) GetClassDashboardMetrics(ctx *QueryContext, facilityID *uint) (ClassDashboardMetrics, error) { - var metrics ClassDashboardMetrics - - var err error - if metrics.ActiveClasses, err = db.GetActiveClassCount(ctx, facilityID); err != nil { - return metrics, err - } - if metrics.TotalSeats, err = db.GetTotalSeatCount(ctx, facilityID); err != nil { - return metrics, err - } - if metrics.ScheduledClasses, err = db.GetScheduledClassCount(ctx, facilityID); err != nil { - return metrics, err - } - if metrics.TotalEnrollments, err = db.GetTotalEnrollmentsCount(ctx, facilityID); err != nil { - return metrics, err - } - if metrics.AttendanceConcerns, err = db.GetAttendanceConcernsCount(ctx, facilityID); err != nil { - return metrics, err - } - - return metrics, nil -} - -func (db *Store) GetActiveClassCount(ctx *QueryContext, facilityID *uint) (int64, error) { - var count int64 - query := db.WithContext(ctx.Ctx). - Model(&ProgramClassCohort{}). - Where("status = ?", Active). - Where("archived_at IS NULL") - if facilityID != nil { - query = query.Where("facility_id = ?", *facilityID) - } - if err := query.Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -func (db *Store) GetTotalSeatCount(ctx *QueryContext, facilityID *uint) (int64, error) { - var totalSeats int64 - query := db.WithContext(ctx.Ctx). - Model(&ProgramClassCohort{}). - Where("status = ?", Active). - Where("archived_at IS NULL") - if facilityID != nil { - query = query.Where("facility_id = ?", *facilityID) - } - if err := query.Select("COALESCE(SUM(capacity), 0)").Scan(&totalSeats).Error; err != nil { - return 0, err - } - return totalSeats, nil -} - -func (db *Store) GetScheduledClassCount(ctx *QueryContext, facilityID *uint) (int64, error) { - var count int64 - query := db.WithContext(ctx.Ctx). - Model(&ProgramClassCohort{}). - Where("status = ?", Scheduled). - Where("archived_at IS NULL") - if facilityID != nil { - query = query.Where("facility_id = ?", *facilityID) - } - if err := query.Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -func (db *Store) GetTotalEnrollmentsCount(ctx *QueryContext, facilityID *uint) (int64, error) { - var count int64 - query := db.WithContext(ctx.Ctx). - Table("program_class_enrollments e"). - Select("COUNT(*)"). - Joins("JOIN program_class_cohorts c ON c.id = e.cohort_id"). - Where("e.enrollment_status = ?", Enrolled). - Where("c.status = ?", Active). - Where("c.archived_at IS NULL") - if facilityID != nil { - query = query.Where("c.facility_id = ?", *facilityID) - } - if err := query.Scan(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -func (db *Store) GetAttendanceConcernsCount(ctx *QueryContext, facilityID *uint) (int64, error) { - attendanceSQL := `SELECT COUNT(DISTINCT c.id) - FROM program_class_cohorts c - JOIN program_class_enrollments e ON e.cohort_id = c.id - WHERE c.status = ? - AND c.archived_at IS NULL - AND e.enrollment_status = ? ` - args := []any{Active, Enrolled} - if facilityID != nil { - attendanceSQL += "AND c.facility_id = ? " - args = append(args, *facilityID) - } - attendanceSQL += ` AND ( - select count(*) - from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id - and att.user_id = e.user_id - and att.attendance_status = 'absent_unexcused' - ) >= 3 ` - var count int64 - if err := db.WithContext(ctx.Ctx).Raw(attendanceSQL, args...).Scan(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -type facilityCount struct { - FacilityID uint `gorm:"column:facility_id"` - Count int64 `gorm:"column:count"` -} - -func (db *Store) GetFacilityHealthSummaries(ctx *QueryContext, facilityID *uint) ([]FacilityHealthSummary, error) { - var summaries []FacilityHealthSummary - query := `SELECT - f.id as facility_id, - f.name as facility_name, - COALESCE(COUNT(DISTINCT p.id), 0) as programs, - COALESCE(COUNT(DISTINCT pc.id), 0) as active_classes, - COALESCE(COUNT(DISTINCT pce.id), 0) as enrollment - FROM facilities f - LEFT JOIN facilities_programs fp - ON fp.facility_id = f.id - AND fp.deleted_at IS NULL - LEFT JOIN programs p - ON p.id = fp.program_id - AND p.is_active = true - AND p.deleted_at IS NULL - AND p.archived_at IS NULL - LEFT JOIN program_class_cohorts pc - ON pc.facility_id = f.id - AND pc.status = ? - AND pc.archived_at IS NULL - LEFT JOIN program_class_enrollments pce - ON pce.cohort_id = pc.id - AND pce.enrollment_status = ? - WHERE f.deleted_at IS NULL ` - args := []any{Active, Enrolled} - if facilityID != nil { - query += " AND f.id = ? " - args = append(args, *facilityID) - } - query += "GROUP BY f.id, f.name ORDER BY f.name" - - if err := db.WithContext(ctx.Ctx).Raw(query, args...).Scan(&summaries).Error; err != nil { - return nil, err - } - return summaries, nil -} - -func (db *Store) GetFacilityAttendanceConcerns(ctx *QueryContext, facilityID *uint) (map[uint]int64, error) { - attendanceSQL := `SELECT c.facility_id as facility_id, COUNT(DISTINCT c.id) as count - FROM program_class_cohorts c - JOIN program_class_enrollments e ON e.cohort_id = c.id - WHERE c.status = ? - AND c.archived_at IS NULL - AND e.enrollment_status = ? ` - args := []any{Active, Enrolled} - if facilityID != nil { - attendanceSQL += "AND c.facility_id = ? " - args = append(args, *facilityID) - } - attendanceSQL += ` AND ( - select count(*) - from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id - and att.user_id = e.user_id - and att.attendance_status = 'absent_unexcused' - ) >= 3 - GROUP BY c.facility_id` - - var counts []facilityCount - if err := db.WithContext(ctx.Ctx).Raw(attendanceSQL, args...).Scan(&counts).Error; err != nil { - return nil, err - } - - results := make(map[uint]int64, len(counts)) - for _, entry := range counts { - results[entry.FacilityID] = entry.Count - } - return results, nil -} diff --git a/backend/src/modules/programs/delete_guards_store.go b/backend/src/modules/programs/delete_guards_store.go deleted file mode 100644 index e78a54e68..000000000 --- a/backend/src/modules/programs/delete_guards_store.go +++ /dev/null @@ -1,88 +0,0 @@ -package programs - -import ( - "database/sql" -) - -// ClassBlockingChildren counts every type of child record attached to a class -// in a single round-trip. Used by the delete-check preflight and by the 409 -// response on DELETE /api/program-classes/{id}. -func (db *Store) ClassBlockingChildren(classID int) (DeleteBlockingChildren, error) { - var b DeleteBlockingChildren - row := db.Raw(` -SELECT - (SELECT COUNT(*) FROM program_class_enrollments - WHERE cohort_id = ? AND deleted_at IS NULL) AS enrollments, - (SELECT COUNT(*) FROM program_class_events - WHERE cohort_id = ? AND deleted_at IS NULL) AS events, - (SELECT COUNT(*) FROM class_completions - WHERE cohort_id = ? AND deleted_at IS NULL) AS completions, - (SELECT COUNT(*) FROM program_class_event_attendance pcea - JOIN program_class_events e ON e.id = pcea.event_id - WHERE e.cohort_id = ? AND pcea.deleted_at IS NULL) AS attendance_flags, - ((SELECT COUNT(*) FROM change_log_entries - WHERE table_name = 'program_class_cohorts' AND parent_ref_id = ?) - + (SELECT COUNT(*) FROM program_classes_history - WHERE table_name = 'program_class_cohorts' AND parent_ref_id = ?) - ) AS history -`, classID, classID, classID, classID, classID, classID).Row() - - if err := row.Scan(&b.Enrollments, &b.Events, &b.Completions, &b.AttendanceFlags, &b.History); err != nil { - return b, newGetRecordsDBError(err, "delete_guard_class_counts") - } - - var status ClassStatus - if err := db.Model(&ProgramClassCohort{}). - Where("id = ?", classID). - Select("status"). - Limit(1). - Scan(&status).Error; err != nil { - return b, newGetRecordsDBError(err, "delete_guard_class_status") - } - if status != "" && status != Scheduled { - b.NonDeletableStatus = string(status) - } - return b, nil -} - -// ProgramBlockingChildren counts every type of child record across every class -// belonging to the program in a single round-trip. b.Classes is the count of -// classes under the program; the other fields aggregate per-class child rows -// across all of them. Program-level change_log entries are added to History. -func (db *Store) ProgramBlockingChildren(programID int) (DeleteBlockingChildren, error) { - var b DeleteBlockingChildren - row := db.Raw(` -WITH cls AS ( - SELECT id FROM program_class_cohorts - WHERE program_id = @pid AND deleted_at IS NULL -) -SELECT - -- Counted off the CLASS tier, not off cls. A class with no cohorts under it must - -- still block: DeleteProgram removes neither program_classes nor program_class_cohorts - -- rows, and there is no DELETE route for the class tier at all -- so a class this - -- guard misses outlives its program with nothing able to reach it again. - (SELECT COUNT(*) FROM program_classes - WHERE program_id = @pid AND deleted_at IS NULL) AS classes, - (SELECT COUNT(*) FROM program_class_enrollments - WHERE cohort_id IN (SELECT id FROM cls) AND deleted_at IS NULL) AS enrollments, - (SELECT COUNT(*) FROM program_class_events - WHERE cohort_id IN (SELECT id FROM cls) AND deleted_at IS NULL) AS events, - (SELECT COUNT(*) FROM class_completions - WHERE cohort_id IN (SELECT id FROM cls) AND deleted_at IS NULL) AS completions, - (SELECT COUNT(*) FROM program_class_event_attendance pcea - JOIN program_class_events e ON e.id = pcea.event_id - WHERE e.cohort_id IN (SELECT id FROM cls) AND pcea.deleted_at IS NULL) AS attendance_flags, - ((SELECT COUNT(*) FROM change_log_entries - WHERE table_name = 'program_class_cohorts' AND parent_ref_id IN (SELECT id FROM cls)) - + (SELECT COUNT(*) FROM program_classes_history - WHERE table_name = 'program_class_cohorts' AND parent_ref_id IN (SELECT id FROM cls)) - + (SELECT COUNT(*) FROM change_log_entries - WHERE table_name = 'programs' AND parent_ref_id = @pid) - ) AS history -`, sql.Named("pid", programID)).Row() - - if err := row.Scan(&b.Classes, &b.Enrollments, &b.Events, &b.Completions, &b.AttendanceFlags, &b.History); err != nil { - return b, newGetRecordsDBError(err, "delete_guard_program_counts") - } - return b, nil -} diff --git a/backend/src/modules/programs/essential_ed_programs.go b/backend/src/modules/programs/essential_ed_programs.go deleted file mode 100644 index a9c9960d8..000000000 --- a/backend/src/modules/programs/essential_ed_programs.go +++ /dev/null @@ -1,539 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "slices" - "strconv" - "strings" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/UnlockedLabs/unlocked-contracts/coreapi" - "github.com/UnlockedLabs/unlocked-contracts/providerplatforms" -) - -// Essential Education's API differs from Canvas in every mechanical respect: -// a token header instead of bearer auth, a required User-Agent, a {records, -// at_end} envelope instead of a bare array, offset paging instead of Link -// headers, and space-separated UTC datetimes. The provider platform's -// account_id holds the district ID; classes are read across the whole district -// rather than per school. - -const ( - // essentialEdPageSize is the API's documented maximum per request. - essentialEdPageSize = 100 - - // essentialEdMaxPages bounds the paging loop. at_end is what normally stops - // it; this only prevents an unbounded loop if the API never sets it. - essentialEdMaxPages = 500 - - // essentialEdTimeFormat is the datetime format used throughout the API. - // Values are UTC. Note this is not RFC3339. - essentialEdTimeFormat = "2006-01-02 15:04:05" - - // essentialEdUserAgent identifies us. The API rejects requests without a - // User-Agent, and does so with an HTML body rather than JSON. - essentialEdUserAgent = "UnlockEdv2" -) - -// essentialEdTransport implements providerTransport for Essential Education. -type essentialEdTransport struct { - srv *server -} - -func (t essentialEdTransport) authorize(req *http.Request, provider *providerplatforms.Provider) { - req.Header.Set("X-API-Token", provider.AccessKey) - req.Header.Set("Accept", "application/json") - // Required. Without it the API returns an HTML error page, which then fails - // to decode with a message that says nothing about the missing header. - req.Header.Set("User-Agent", essentialEdUserAgent) -} - -// fetchAll walks the start/limit pages until the API reports at_end. -func (t essentialEdTransport) fetchAll(ctx context.Context, provider *providerplatforms.Provider, startURL string, max int) ([]map[string]interface{}, error) { - parsed, err := url.Parse(startURL) - if err != nil { - return nil, err - } - query := parsed.Query() - - var all []map[string]interface{} - for page := 0; page < essentialEdMaxPages; page++ { - query.Set("start", strconv.Itoa(len(all))) - query.Set("limit", strconv.Itoa(essentialEdPageSize)) - parsed.RawQuery = query.Encode() - - envelope, err := t.fetchPage(ctx, provider, parsed.String()) - if err != nil { - return nil, err - } - all = append(all, envelope.Records...) - - // An empty page also terminates: without it, an at_end that never flips - // would spin until the page cap. - if envelope.AtEnd || len(envelope.Records) == 0 { - break - } - if max > 0 && len(all) >= max { - break - } - } - return all, nil -} - -// essentialEdPage is the paginated response envelope. -type essentialEdPage struct { - Records []map[string]interface{} `json:"records"` - AtEnd bool `json:"at_end"` -} - -func (t essentialEdTransport) fetchPage(ctx context.Context, provider *providerplatforms.Provider, pageURL string) (essentialEdPage, error) { - var envelope essentialEdPage - if err := requireHTTPS(pageURL); err != nil { - return envelope, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) - if err != nil { - return envelope, err - } - t.authorize(req, provider) - resp, err := t.srv.Client.Do(req) - if err != nil { - return envelope, err - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return envelope, fmt.Errorf("essential ed API returned %d: %s", resp.StatusCode, readErrorBody(resp)) - } - if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { - return envelope, fmt.Errorf("decoding essential ed response: %w", err) - } - return envelope, nil -} - -func (t essentialEdTransport) fetchOne(ctx context.Context, provider *providerplatforms.Provider, recordURL string) (map[string]interface{}, error) { - if err := requireHTTPS(recordURL); err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, recordURL, nil) - if err != nil { - return nil, err - } - t.authorize(req, provider) - resp, err := t.srv.Client.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("essential ed API returned %d: %s", resp.StatusCode, readErrorBody(resp)) - } - var record map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { - return nil, err - } - return record, nil -} - -// essentialEdClassesURL is the district-wide class listing. The token's scope -// determines which schools are included, so no school filter is applied. -func essentialEdClassesURL(provider *providerplatforms.Provider) string { - return strings.TrimSuffix(provider.BaseURL, "/") + "/classes" -} - -// essentialEdProvider is the Essential Education implementation of LiveProgramProvider. -type essentialEdProvider struct { - srv *server - provider *providerplatforms.Provider - transport providerTransport -} - -func (p essentialEdProvider) ListCourses(ctx context.Context) (liveCourseList, error) { - raw, err := p.transport.fetchAll(ctx, p.provider, essentialEdClassesURL(p.provider), 0) - if err != nil { - return liveCourseList{}, fmt.Errorf("essential ed API error for provider %d: %w", p.provider.ID, err) - } - now := time.Now() - courses := make([]liveCourse, 0, len(raw)) - for _, record := range raw { - course, ok := parseEssentialEdClass(record, p.provider.ID, now) - if !ok { - continue - } - courses = append(courses, course) - } - return liveCourseList{ - Courses: courses, - // Essential Ed reports no per-class timezone; callers fall back to the - // facility default. - Timezones: map[uint]string{}, - Total: len(raw), - }, nil -} - -// ListCourseEnrollees re-reads the class listing to find one class's students. -// Callers that already hold a liveCourse should go through courseEnrollees -// instead, which uses the inline enrolleeIDs the listing supplied and costs no -// request; this is only for the paths that have nothing but a course ID. -func (p essentialEdProvider) ListCourseEnrollees(ctx context.Context, rawCourseID uint) ([]string, error) { - listing, err := p.ListCourses(ctx) - if err != nil { - return nil, err - } - for _, course := range listing.Courses { - if course.rawID == rawCourseID { - return course.enrolleeIDs, nil - } - } - return nil, fmt.Errorf("essential ed class %d not found for provider %d", rawCourseID, p.provider.ID) -} - -// GetCourse reads the class listing and picks out one class. Essential Ed has no -// documented single-class endpoint, and the listing is what carries the inline -// students, so re-reading it keeps the detail view identical to the list view. -// Essential Ed reports no per-class timezone. -func (p essentialEdProvider) GetCourse(ctx context.Context, rawCourseID uint) (liveCourse, string, error) { - listing, err := p.ListCourses(ctx) - if err != nil { - return liveCourse{}, "", err - } - for _, course := range listing.Courses { - if course.rawID == rawCourseID { - return course, "", nil - } - } - return liveCourse{}, "", fmt.Errorf("essential ed class %d not found for provider %d", rawCourseID, p.provider.ID) -} - -// ListUserCourses returns the classes a student is enrolled in. The class -// listing carries its students inline, so this filters that rather than asking -// for a per-user endpoint Essential Ed does not have. -func (p essentialEdProvider) ListUserCourses(ctx context.Context, externalUserID string) ([]liveCourse, error) { - listing, err := p.ListCourses(ctx) - if err != nil { - return nil, err - } - var enrolled []liveCourse - for _, course := range listing.Courses { - if slices.Contains(course.enrolleeIDs, externalUserID) { - enrolled = append(enrolled, course) - } - } - return enrolled, nil -} - -// parseEssentialEdClass maps one class record onto liveCourse. Returns false for -// records that cannot be represented, rather than guessing. -func parseEssentialEdClass(record map[string]interface{}, providerID uint, now time.Time) (liveCourse, bool) { - rawID, ok := essentialEdUint(record["id"]) - if !ok { - return liveCourse{}, false - } - if !courseIDFitsEncoding(rawID) { - log.Warnf("essential ed class %d exceeds the synthetic class ID range, skipping", rawID) - return liveCourse{}, false - } - - name, _ := record["name"].(string) - startDt := essentialEdTime(record["open_date"]) - endDt := essentialEdTimePtr(record["close_date"]) - - // A class is Completed when the provider has disabled it or its close date - // has passed. Canvas has no active flag and infers status from dates alone. - status := Active - if active, known := essentialEdBool(record["active"]); known && !active { - status = Completed - } else if endDt != nil && !endDt.After(now) { - status = Completed - } - - return liveCourse{ - encodedID: encodeCanvasClassID(providerID, rawID), - rawID: rawID, - name: name, - // No course_code analogue exists in the class record. - description: "", - startDt: startDt, - endDt: endDt, - status: status, - enrolleeIDs: essentialEdStudentIDs(record["students"]), - weeklySchedule: essentialEdWeeklySchedule(record), - }, true -} - -// essentialEdScheduleFields maps the per-weekday class record fields onto the -// weekday they describe. -var essentialEdScheduleFields = map[string]time.Weekday{ - "schedule_sun": time.Sunday, - "schedule_mon": time.Monday, - "schedule_tue": time.Tuesday, - "schedule_wed": time.Wednesday, - "schedule_thu": time.Thursday, - "schedule_fri": time.Friday, - "schedule_sat": time.Saturday, -} - -// essentialEdWeeklySchedule reads the recurring meeting pattern off a class -// record. Days with no parseable times are left out entirely, so an empty result -// means the class has no usable schedule rather than an empty week. -func essentialEdWeeklySchedule(record map[string]interface{}) map[time.Weekday][]meetingTime { - schedule := make(map[time.Weekday][]meetingTime, len(essentialEdScheduleFields)) - for field, weekday := range essentialEdScheduleFields { - text, _ := record[field].(string) - if times := parseMeetingTimes(text); len(times) > 0 { - schedule[weekday] = times - } - } - if len(schedule) == 0 { - return nil - } - return schedule -} - -// meetingTimeSeparators are the ways instructors separate a start from an end. -// The field is free-form text, so the recommended "9:00 AM - 11:00 AM" is only -// the most likely of several spellings. -var meetingTimeSeparators = []string{"--", "-", "\u2013", "\u2014", " to ", " until "} - -// parseMeetingTimes reads one day's free-form meeting times. The documented -// format is comma separated ranges with a hyphen between start and end, but the -// field is typed by instructors, so anything unparseable is skipped rather than -// guessed at. -func parseMeetingTimes(text string) []meetingTime { - text = strings.TrimSpace(text) - if text == "" { - return nil - } - var times []meetingTime - for _, part := range strings.Split(text, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - start, end, ok := splitMeetingRange(part) - if !ok { - continue - } - startMin, ok := parseClockMinutes(start) - if !ok { - continue - } - endMin, ok := parseClockMinutes(end) - if !ok || endMin <= startMin { - continue - } - times = append(times, meetingTime{startMin: startMin, endMin: endMin}) - } - return times -} - -// splitMeetingRange splits "9:00 AM - 11:00 AM" into its two halves. -func splitMeetingRange(part string) (string, string, bool) { - // Lowercasing can change byte length for exotic runes; fall back to the - // original string when it does, so the split indexes stay valid. - lower := strings.ToLower(part) - if len(lower) != len(part) { - lower = part - } - for _, sep := range meetingTimeSeparators { - if idx := strings.Index(lower, sep); idx > 0 { - return part[:idx], part[idx+len(sep):], true - } - } - return "", "", false -} - -// parseClockMinutes reads a single clock time as minutes past midnight, -// accepting 12-hour times with a meridiem and bare 24-hour times. -func parseClockMinutes(value string) (int, bool) { - value = strings.ToLower(strings.TrimSpace(value)) - value = strings.ReplaceAll(value, ".", "") - pm := strings.HasSuffix(value, "pm") - am := strings.HasSuffix(value, "am") - if pm || am { - value = strings.TrimSpace(value[:len(value)-2]) - } - hourStr, minStr, hasMinutes := strings.Cut(value, ":") - hour, err := strconv.Atoi(strings.TrimSpace(hourStr)) - if err != nil { - return 0, false - } - minute := 0 - if hasMinutes { - if minute, err = strconv.Atoi(strings.TrimSpace(minStr)); err != nil { - return 0, false - } - } - if minute < 0 || minute > 59 { - return 0, false - } - switch { - case pm && hour != 12: - hour += 12 - case am && hour == 12: - hour = 0 - } - if hour < 0 || hour > 23 { - return 0, false - } - return hour*60 + minute, true -} - -// essentialEdStudentIDs pulls the provider's student IDs out of the inline -// students array. Returns an empty (non-nil) slice when the key is present but -// empty, so callers can tell "no students" from "not supplied". -func essentialEdStudentIDs(value any) []string { - students, ok := value.([]interface{}) - if !ok { - return nil - } - ids := make([]string, 0, len(students)) - for _, entry := range students { - student, ok := entry.(map[string]interface{}) - if !ok { - continue - } - if id, ok := essentialEdUint(student["id"]); ok { - ids = append(ids, strconv.FormatUint(uint64(id), 10)) - } - } - return ids -} - -// essentialEdBool reads a boolean. The docs say responses always use 1/0, but -// the API returns real JSON booleans on some endpoints, so both are accepted. -// The second return distinguishes "false" from "absent". -func essentialEdBool(value any) (result bool, known bool) { - switch v := value.(type) { - case bool: - return v, true - case float64: - return v != 0, true - case string: - switch strings.ToLower(strings.TrimSpace(v)) { - case "1", "y", "yes", "t", "true": - return true, true - case "0", "n", "no", "f", "false": - return false, true - } - } - return false, false -} - -// essentialEdUint reads an ID, which JSON decoding gives us as a float64. -func essentialEdUint(value any) (uint, bool) { - switch v := value.(type) { - case float64: - if v < 0 { - return 0, false - } - return uint(v), true - case string: - parsed, err := strconv.ParseUint(strings.TrimSpace(v), 10, 64) - if err != nil { - return 0, false - } - return uint(parsed), true - } - return 0, false -} - -// essentialEdTime parses a datetime, returning the zero time when absent or -// malformed. Values are UTC and use a space separator, not RFC3339. -func essentialEdTime(value any) time.Time { - text, ok := value.(string) - if !ok || text == "" { - return time.Time{} - } - parsed, err := time.ParseInLocation(essentialEdTimeFormat, text, time.UTC) - if err != nil { - return time.Time{} - } - return parsed -} - -// essentialEdTimePtr is essentialEdTime for optional fields, which arrive as -// JSON null. Returns nil when there is no usable value. -func essentialEdTimePtr(value any) *time.Time { - parsed := essentialEdTime(value) - if parsed.IsZero() { - return nil - } - return &parsed -} - -// essentialEdUsersURL is the district-wide user listing, narrowed to students. -// roles must be sent as an array parameter -- a bare roles=STUDENT is rejected -// with "The roles parameter must be an array." -func essentialEdUsersURL(provider *providerplatforms.Provider) string { - query := url.Values{"roles[]": {"STUDENT"}} - return strings.TrimSuffix(provider.BaseURL, "/") + "/users?" + query.Encode() -} - -// ListUsers returns the district's student accounts. Implementing this lets the -// user-matching screen read Essential Ed directly instead of going through -// Provider Platforms' worker, which has no Essential Ed service. -func (p essentialEdProvider) ListUsers(ctx context.Context) ([]coreapi.ProviderUser, error) { - raw, err := p.transport.fetchAll(ctx, p.provider, essentialEdUsersURL(p.provider), 0) - if err != nil { - return nil, fmt.Errorf("essential ed users error for provider %d: %w", p.provider.ID, err) - } - users := make([]coreapi.ProviderUser, 0, len(raw)) - for _, record := range raw { - user, ok := parseEssentialEdUser(record) - if !ok { - continue - } - users = append(users, user) - } - return users, nil -} - -// parseEssentialEdUser maps a user record onto ImportUser. Note the response -// field is email_address, not the "email" the published docs name. -func parseEssentialEdUser(record map[string]interface{}) (coreapi.ProviderUser, bool) { - id, ok := essentialEdUint(record["id"]) - if !ok { - return coreapi.ProviderUser{}, false - } - nameFirst, _ := record["first_name"].(string) - nameLast, _ := record["last_name"].(string) - email, _ := record["email_address"].(string) - - // Students authenticate with a username and often have no email; staff are - // the reverse. Fall back so the matching screen always has a handle to show. - username, _ := record["username"].(string) - if username == "" { - username = email - } - if username == "" { - username = strconv.FormatUint(uint64(id), 10) - } - - return coreapi.ProviderUser{ - ExternalUserID: strconv.FormatUint(uint64(id), 10), - ExternalUsername: username, - Username: username, - NameFirst: nameFirst, - NameLast: nameLast, - Email: email, - }, true -} - -// readErrorBody returns a short excerpt of an error response. Essential Ed -// explains 4xx failures in the body (for example "The roles parameter must be -// an array."), which the status code alone hides. -func readErrorBody(resp *http.Response) string { - const limit = 512 - body, err := io.ReadAll(io.LimitReader(resp.Body, limit)) - if err != nil || len(body) == 0 { - return "no response body" - } - return strings.TrimSpace(string(body)) -} diff --git a/backend/src/modules/programs/essential_ed_programs_test.go b/backend/src/modules/programs/essential_ed_programs_test.go deleted file mode 100644 index f358d9056..000000000 --- a/backend/src/modules/programs/essential_ed_programs_test.go +++ /dev/null @@ -1,911 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/UnlockedLabs/unlocked-contracts/coreapi" - "github.com/UnlockedLabs/unlocked-contracts/providerplatforms" -) - -// The payloads here mirror what the real sandbox returns, including the quirks -// the published docs get wrong: classes report active as 1/0 while users report -// it as a JSON boolean, close_date arrives as null, and datetimes are space -// separated rather than RFC3339. - -func essentialEdServer(t *testing.T, handler http.HandlerFunc) (*server, *providerplatforms.Provider, func()) { - t.Helper() - ts := httptest.NewTLSServer(handler) - srv := &server{Client: ts.Client()} - provider := &providerplatforms.Provider{ - BaseURL: ts.URL, - AccountID: "303", - AccessKey: "test-token", - Type: providerplatforms.EssentialEd, - } - return srv, provider, ts.Close -} - -func envelope(records []map[string]interface{}, atEnd bool) map[string]interface{} { - return map[string]interface{}{"records": records, "at_end": atEnd} -} - -func TestEssentialEdTransportAuthorize(t *testing.T) { - var gotToken, gotAgent, gotAccept string - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - gotToken = r.Header.Get("X-API-Token") - gotAgent = r.Header.Get("User-Agent") - gotAccept = r.Header.Get("Accept") - _ = json.NewEncoder(w).Encode(envelope(nil, true)) - }) - defer done() - - transport := essentialEdTransport{srv: srv} - _, err := transport.fetchAll(context.Background(), provider, provider.BaseURL+"/classes", 0) - - assert.NoError(t, err) - assert.Equal(t, "test-token", gotToken) - assert.Equal(t, "application/json", gotAccept) - // Required: the API answers with an HTML error page when it is missing. - assert.NotEmpty(t, gotAgent, "User-Agent must always be sent") -} - -func TestEssentialEdTransportFetchAll(t *testing.T) { - t.Run("walks start/limit pages until at_end", func(t *testing.T) { - var starts []string - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - starts = append(starts, r.URL.Query().Get("start")) - assert.Equal(t, "100", r.URL.Query().Get("limit")) - if len(starts) == 1 { - page := make([]map[string]interface{}, 100) - for i := range page { - page[i] = map[string]interface{}{"id": float64(i + 1)} - } - _ = json.NewEncoder(w).Encode(envelope(page, false)) - return - } - _ = json.NewEncoder(w).Encode(envelope( - []map[string]interface{}{{"id": float64(101)}}, true)) - }) - defer done() - - transport := essentialEdTransport{srv: srv} - out, err := transport.fetchAll(context.Background(), provider, provider.BaseURL+"/classes", 0) - - assert.NoError(t, err) - assert.Len(t, out, 101) - assert.Equal(t, []string{"0", "100"}, starts, "start advances by the number already collected") - }) - - t.Run("preserves query params already on the url", func(t *testing.T) { - var gotSchool string - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - gotSchool = r.URL.Query().Get("school_id") - _ = json.NewEncoder(w).Encode(envelope(nil, true)) - }) - defer done() - - transport := essentialEdTransport{srv: srv} - _, err := transport.fetchAll(context.Background(), provider, provider.BaseURL+"/classes?school_id=6592", 0) - - assert.NoError(t, err) - assert.Equal(t, "6592", gotSchool) - }) - - // at_end is the normal terminator; an empty page must also stop the loop so a - // server that never sets at_end cannot spin to the page cap. - t.Run("an empty page terminates even when at_end stays false", func(t *testing.T) { - calls := 0 - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - calls++ - _ = json.NewEncoder(w).Encode(envelope([]map[string]interface{}{}, false)) - }) - defer done() - - transport := essentialEdTransport{srv: srv} - out, err := transport.fetchAll(context.Background(), provider, provider.BaseURL+"/classes", 0) - - assert.NoError(t, err) - assert.Empty(t, out) - assert.Equal(t, 1, calls) - }) - - t.Run("non-200 is an error", func(t *testing.T) { - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - }) - defer done() - - transport := essentialEdTransport{srv: srv} - _, err := transport.fetchAll(context.Background(), provider, provider.BaseURL+"/classes", 0) - assert.Error(t, err) - }) - - // Without a User-Agent the real API returns HTML, so a decode failure here - // must surface as an error rather than an empty result set. - t.Run("an html body is an error, not an empty listing", func(t *testing.T) { - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("

Sorry, you don't have access to this resource.

")) - }) - defer done() - - transport := essentialEdTransport{srv: srv} - _, err := transport.fetchAll(context.Background(), provider, provider.BaseURL+"/classes", 0) - assert.Error(t, err) - }) -} - -func TestParseEssentialEdClass(t *testing.T) { - now := time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC) - - // Verbatim shape of a real sandbox class record. - sandboxClass := func() map[string]interface{} { - return map[string]interface{}{ - "id": float64(69742), - "active": float64(1), - "name": "College Prep I", - "open_date": "2026-01-31 00:00:00", - "close_date": nil, - "schedule_mon": "1pm-2pm", - "school_id": float64(6592), - "owner_id": float64(4329931), - "created_at": "2026-09-15 19:58:59", - "students": []interface{}{ - map[string]interface{}{"id": float64(4329946), "first_name": "UnlockedLabs", "last_name": "Student1"}, - map[string]interface{}{"id": float64(4329947), "first_name": "UnlockedLabs", "last_name": "Student2"}, - }, - } - } - - t.Run("maps a real class record", func(t *testing.T) { - course, ok := parseEssentialEdClass(sandboxClass(), 2, now) - - assert.True(t, ok) - assert.Equal(t, uint(69742), course.rawID) - assert.Equal(t, encodeCanvasClassID(2, 69742), course.encodedID) - assert.Equal(t, "College Prep I", course.name) - assert.Equal(t, Active, course.status) - assert.Equal(t, 2026, course.startDt.Year()) - assert.Equal(t, time.January, course.startDt.Month()) - assert.Nil(t, course.endDt, "a null close_date is no end date") - assert.Equal(t, []string{"4329946", "4329947"}, course.enrolleeIDs) - }) - - t.Run("active=0 marks the class Completed", func(t *testing.T) { - record := sandboxClass() - record["active"] = float64(0) - course, ok := parseEssentialEdClass(record, 2, now) - assert.True(t, ok) - assert.Equal(t, Completed, course.status) - }) - - t.Run("a past close_date marks the class Completed", func(t *testing.T) { - record := sandboxClass() - record["close_date"] = "2020-06-01 00:00:00" - course, ok := parseEssentialEdClass(record, 2, now) - assert.True(t, ok) - assert.Equal(t, Completed, course.status) - assert.NotNil(t, course.endDt) - }) - - t.Run("a future close_date stays Active", func(t *testing.T) { - record := sandboxClass() - record["close_date"] = "2030-06-01 00:00:00" - course, ok := parseEssentialEdClass(record, 2, now) - assert.True(t, ok) - assert.Equal(t, Active, course.status) - }) - - t.Run("a class with no students gets an empty, non-nil enrollee list", func(t *testing.T) { - record := sandboxClass() - record["students"] = []interface{}{} - course, ok := parseEssentialEdClass(record, 2, now) - assert.True(t, ok) - assert.NotNil(t, course.enrolleeIDs, "empty differs from not supplied") - assert.Empty(t, course.enrolleeIDs) - }) - - t.Run("a missing id is rejected", func(t *testing.T) { - record := sandboxClass() - delete(record, "id") - _, ok := parseEssentialEdClass(record, 2, now) - assert.False(t, ok) - }) - - // The encoding packs the course into the low six digits. Essential Ed user - // IDs already exceed that, so class IDs plausibly will too -- encoding one - // would silently attribute the class to a different provider. - t.Run("a course id too large to encode is skipped", func(t *testing.T) { - record := sandboxClass() - record["id"] = float64(1_000_001) - _, ok := parseEssentialEdClass(record, 2, now) - assert.False(t, ok) - }) - - t.Run("a malformed date does not fail the class", func(t *testing.T) { - record := sandboxClass() - record["open_date"] = "31/01/2026" - course, ok := parseEssentialEdClass(record, 2, now) - assert.True(t, ok) - assert.True(t, course.startDt.IsZero()) - }) -} - -func TestEssentialEdBool(t *testing.T) { - tests := []struct { - name string - value any - want bool - known bool - }{ - {"integer one, as classes report it", float64(1), true, true}, - {"integer zero", float64(0), false, true}, - {"json true, as users report it", true, true, true}, - {"json false", false, false, true}, - {"string one", "1", true, true}, - {"string yes", "yes", true, true}, - {"string false", "false", false, true}, - {"absent", nil, false, false}, - {"unrecognized string", "maybe", false, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, known := essentialEdBool(tt.value) - assert.Equal(t, tt.known, known) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestEssentialEdTime(t *testing.T) { - t.Run("parses the space separated utc format", func(t *testing.T) { - got := essentialEdTime("2026-01-31 13:45:09") - assert.Equal(t, 2026, got.Year()) - assert.Equal(t, 13, got.Hour()) - assert.Equal(t, time.UTC, got.Location()) - }) - - for _, bad := range []any{nil, "", "2026-01-31T13:45:09Z", float64(12345), "not a date"} { - t.Run(fmt.Sprintf("zero for %v", bad), func(t *testing.T) { - assert.True(t, essentialEdTime(bad).IsZero()) - }) - } - - t.Run("pointer variant is nil when there is no value", func(t *testing.T) { - assert.Nil(t, essentialEdTimePtr(nil)) - assert.NotNil(t, essentialEdTimePtr("2026-01-31 00:00:00")) - }) -} - -func TestEssentialEdProviderListCourses(t *testing.T) { - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/classes", r.URL.Path) - _ = json.NewEncoder(w).Encode(envelope([]map[string]interface{}{ - { - "id": float64(69742), "active": float64(1), "name": "College Prep I", - "open_date": "2026-01-31 00:00:00", "close_date": nil, - "students": []interface{}{map[string]interface{}{"id": float64(4329946)}}, - }, - {"id": "bogus", "name": "Unparseable"}, - }, true)) - }) - defer done() - - reader, err := srv.newLiveProgramProvider(provider) - assert.NoError(t, err) - assert.IsType(t, essentialEdProvider{}, reader) - - listing, err := reader.ListCourses(context.Background()) - assert.NoError(t, err) - assert.Len(t, listing.Courses, 1) - assert.Equal(t, 2, listing.Total, "unparseable records still count toward the total") - assert.Empty(t, listing.Timezones, "essential ed reports no per-class timezone") - - // Inline students mean no extra request is needed for enrollees. - ids, err := courseEnrollees(context.Background(), reader, listing.Courses[0]) - assert.NoError(t, err) - assert.Equal(t, []string{"4329946"}, ids) -} - -func TestEssentialEdProviderIsLive(t *testing.T) { - provider := &providerplatforms.Provider{Type: providerplatforms.EssentialEd} - assert.True(t, isLiveProgramProvider(provider)) - assert.False(t, isCanvasProvider(provider), "essential ed must not be mistaken for canvas") - assert.Equal(t, "essential_ed", providerSourceLabel(provider)) - - srv := &server{} - transport, err := srv.transportFor(provider) - assert.NoError(t, err) - assert.IsType(t, essentialEdTransport{}, transport) -} - -func TestCourseIDFitsEncoding(t *testing.T) { - assert.True(t, courseIDFitsEncoding(0)) - assert.True(t, courseIDFitsEncoding(69742)) - assert.True(t, courseIDFitsEncoding(maxRawCourseID)) - assert.False(t, courseIDFitsEncoding(maxRawCourseID+1)) -} - -// The enrollee counters used to call the Canvas-specific fetch directly, which -// built a Canvas URL against whatever base_url the provider had. They now go -// through the reader, so a non-Canvas provider resolves its own students. -func TestProviderCourseEnrolleeIDsDispatchesByType(t *testing.T) { - t.Run("essential ed resolves students from the class listing", func(t *testing.T) { - var paths []string - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - paths = append(paths, r.URL.Path) - _ = json.NewEncoder(w).Encode(envelope([]map[string]interface{}{{ - "id": float64(69742), "active": float64(1), "name": "College Prep I", - "students": []interface{}{ - map[string]interface{}{"id": float64(4329946)}, - map[string]interface{}{"id": float64(4329947)}, - }, - }}, true)) - }) - defer done() - - ids, err := srv.providerCourseEnrolleeIDs(provider, 69742) - assert.NoError(t, err) - assert.Equal(t, []string{"4329946", "4329947"}, ids) - assert.Equal(t, []string{"/classes"}, paths, "no canvas enrollments endpoint is called") - }) - - t.Run("an unknown course id is an error, not a silent empty list", func(t *testing.T) { - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(envelope(nil, true)) - }) - defer done() - - _, err := srv.providerCourseEnrolleeIDs(provider, 999) - assert.Error(t, err) - }) - - t.Run("a non-live provider is rejected", func(t *testing.T) { - srv := &server{} - _, err := srv.providerCourseEnrolleeIDs(&providerplatforms.Provider{Type: providerplatforms.Kolibri}, 1) - assert.Error(t, err) - }) -} - -func TestEssentialEdListUsers(t *testing.T) { - // Real sandbox shapes: students carry a username and no email, staff the - // reverse, and the field is email_address rather than the documented email. - t.Run("maps student and staff records", func(t *testing.T) { - var gotPath, gotQuery string - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - gotPath, gotQuery = r.URL.Path, r.URL.Query().Get("roles[]") - _ = json.NewEncoder(w).Encode(envelope([]map[string]interface{}{ - { - "id": float64(4329946), "first_name": "UnlockedLabs", "last_name": "Student1", - "username": "student1", "email_address": "", "active": true, - }, - { - "id": float64(4329931), "first_name": "UnlockedLabs", "last_name": "Teacher1", - "username": "", "email_address": "teacher1@essentialed.com", "active": true, - }, - }, true)) - }) - defer done() - - reader, err := srv.newLiveProgramProvider(provider) - assert.NoError(t, err) - lister, ok := reader.(liveUserLister) - assert.True(t, ok, "essential ed must implement liveUserLister") - - users, err := lister.ListUsers(context.Background()) - assert.NoError(t, err) - assert.Equal(t, "/users", gotPath) - assert.Equal(t, "STUDENT", gotQuery, "roles must be sent as an array parameter") - assert.Len(t, users, 2) - - assert.Equal(t, "4329946", users[0].ExternalUserID) - assert.Equal(t, "student1", users[0].Username) - assert.Equal(t, "UnlockedLabs", users[0].NameFirst) - assert.Equal(t, "Student1", users[0].NameLast) - - // No username: fall back to the email so the match screen has a handle. - assert.Equal(t, "teacher1@essentialed.com", users[1].Username) - }) - - t.Run("a record with no id is skipped", func(t *testing.T) { - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(envelope([]map[string]interface{}{ - {"first_name": "No", "last_name": "Id"}, - }, true)) - }) - defer done() - - reader, _ := srv.newLiveProgramProvider(provider) - users, err := reader.(liveUserLister).ListUsers(context.Background()) - assert.NoError(t, err) - assert.Empty(t, users) - }) -} - -// The schedule fields are free-form text typed by instructors, so the parser -// takes the documented format and the spellings people actually use, and skips -// anything it cannot read rather than guessing. -func TestParseMeetingTimes(t *testing.T) { - tests := []struct { - name string - text string - want []meetingTime - }{ - {"documented format", "9:00 AM - 11:00 AM", []meetingTime{{540, 660}}}, - {"no spaces around the hyphen", "9:00AM-11:00AM", []meetingTime{{540, 660}}}, - {"24 hour times", "09:00-11:30", []meetingTime{{540, 690}}}, - {"bare hours", "9-11", []meetingTime{{540, 660}}}, - {"two ranges in one day", "9:00 AM - 11:00 AM, 1:00 PM - 2:30 PM", []meetingTime{{540, 660}, {780, 870}}}, - {"en dash", "9:00 AM – 11:00 AM", []meetingTime{{540, 660}}}, - {"the word to", "9:00 am to 11:00 am", []meetingTime{{540, 660}}}, - {"noon and midnight", "12:00 AM - 12:30 PM", []meetingTime{{0, 750}}}, - {"empty", "", nil}, - {"free text with no times", "by appointment", nil}, - {"end before start is dropped", "11:00 AM - 9:00 AM", nil}, - {"a bad range does not lose a good one", "nonsense, 1:00 PM - 2:00 PM", []meetingTime{{780, 840}}}, - {"minutes out of range", "9:75 - 11:00", nil}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, parseMeetingTimes(tt.text)) - }) - } -} - -func TestEssentialEdWeeklySchedule(t *testing.T) { - t.Run("reads only the days that carry times", func(t *testing.T) { - schedule := essentialEdWeeklySchedule(map[string]interface{}{ - "schedule_mon": "9:00 AM - 11:00 AM", - "schedule_wed": "9:00 AM - 11:00 AM", - "schedule_tue": "", - "schedule_fri": "by appointment", - }) - assert.Len(t, schedule, 2) - assert.Equal(t, []meetingTime{{540, 660}}, schedule[time.Monday]) - assert.Equal(t, []meetingTime{{540, 660}}, schedule[time.Wednesday]) - assert.NotContains(t, schedule, time.Tuesday) - assert.NotContains(t, schedule, time.Friday, "unparseable text is not an empty meeting") - }) - - t.Run("a class with no schedule reports none", func(t *testing.T) { - assert.Nil(t, essentialEdWeeklySchedule(map[string]interface{}{"name": "College Prep I"})) - }) -} - -// The class detail page reads one class by ID. Essential Ed has no single-class -// endpoint, so it filters the listing -- the students stay inline either way. -func TestEssentialEdGetCourse(t *testing.T) { - classes := []map[string]interface{}{ - { - "id": float64(69742), "active": float64(1), "name": "College Prep I", - "open_date": "2026-01-31 00:00:00", "close_date": nil, - "students": []interface{}{map[string]interface{}{"id": float64(4329946)}}, - }, - { - "id": float64(69743), "active": float64(1), "name": "High School Prep I", - "open_date": "2026-01-31 00:00:00", "close_date": nil, - "students": []interface{}{map[string]interface{}{"id": float64(4329947)}}, - }, - } - - tests := []struct { - name string - rawCourseID uint - wantName string - wantErr bool - }{ - {"returns the requested class", 69743, "High School Prep I", false}, - {"returns the other class", 69742, "College Prep I", false}, - {"an unknown class is an error, not an empty course", 12345, "", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/classes", r.URL.Path) - _ = json.NewEncoder(w).Encode(envelope(classes, true)) - }) - defer done() - - reader, err := srv.newLiveProgramProvider(provider) - assert.NoError(t, err) - - course, timezone, err := reader.GetCourse(context.Background(), tt.rawCourseID) - if tt.wantErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - assert.Equal(t, tt.wantName, course.name) - assert.Equal(t, tt.rawCourseID, course.rawID) - assert.NotEmpty(t, course.enrolleeIDs, "students come back inline") - assert.Empty(t, timezone, "essential ed reports no per-class timezone") - }) - } -} - -// A weekly pattern has to become dated events before the month calendar can -// render it, bounded by the class's own open and close dates. -func TestExpandWeeklySchedule(t *testing.T) { - // March 2026: the 2nd is a Monday, the 4th a Wednesday. - march := func(day int) time.Time { return time.Date(2026, time.March, day, 0, 0, 0, 0, time.UTC) } - chicago, err := time.LoadLocation("America/Chicago") - assert.NoError(t, err) - course := liveCourse{ - name: "College Prep I", - startDt: march(1), - weeklySchedule: map[time.Weekday][]meetingTime{ - time.Monday: {{540, 660}}, - time.Wednesday: {{540, 660}, {780, 840}}, - }, - } - - t.Run("expands each meeting across the range", func(t *testing.T) { - events := expandWeeklySchedule(course, march(2), march(8), time.UTC) - assert.Len(t, events, 3, "one Monday meeting and two on Wednesday") - assert.Equal(t, "College Prep I", events[0].Title) - assert.Equal(t, march(2).Add(9*time.Hour), events[0].StartAt) - assert.Equal(t, march(2).Add(11*time.Hour), events[0].EndAt) - assert.Equal(t, march(4).Add(13*time.Hour), events[2].StartAt) - }) - - t.Run("days before the class opens are skipped", func(t *testing.T) { - late := course - late.startDt = march(3) - events := expandWeeklySchedule(late, march(2), march(8), time.UTC) - assert.Len(t, events, 2, "the Monday before the open date is dropped") - }) - - t.Run("days after the class closes are skipped", func(t *testing.T) { - closed := course - end := march(3) - closed.endDt = &end - events := expandWeeklySchedule(closed, march(2), march(8), time.UTC) - assert.Len(t, events, 1, "only the Monday falls inside the close date") - }) - - // The meeting text has no zone, so 9:00 AM has to mean 9:00 AM at the facility - // rather than 9:00 UTC -- otherwise the calendar renders it hours off. - t.Run("times are wall-clock in the facility timezone", func(t *testing.T) { - events := expandWeeklySchedule(course, march(2), march(8), chicago) - assert.Len(t, events, 3) - assert.Equal(t, 9, events[0].StartAt.In(chicago).Hour()) - assert.Equal(t, 11, events[0].EndAt.In(chicago).Hour()) - assert.Equal(t, "America/Chicago", events[0].Timezone) - assert.Equal(t, 15, events[0].StartAt.UTC().Hour(), "9am CST is 15:00 UTC") - }) - - // The calendar hands this function UTC midnights converted into the facility's - // zone, so each day is 6pm the previous local evening -- an instant already past - // a close date the provider reported as bare UTC midnight. Comparing instants - // rather than dates dropped the meetings on the real last day of class. - t.Run("open and close dates bound the facility's calendar days, not UTC instants", func(t *testing.T) { - closed := course - closeDate := march(4) // the class closes on the Wednesday - closed.endDt = &closeDate - events := expandWeeklySchedule(closed, march(2).In(chicago), march(8).In(chicago), chicago) - assert.Len(t, events, 3, "the Monday plus both Wednesday meetings") - assert.Equal(t, 4, events[2].StartAt.In(chicago).Day(), "the last day of class is kept") - - late := course - late.startDt = march(3) - events = expandWeeklySchedule(late, march(2).In(chicago), march(8).In(chicago), chicago) - assert.Len(t, events, 2, "the Monday before the open date is dropped") - assert.Equal(t, 4, events[0].StartAt.In(chicago).Day()) - }) - - t.Run("a class with no schedule yields no events", func(t *testing.T) { - assert.Empty(t, expandWeeklySchedule(liveCourse{name: "No Schedule"}, march(2), march(8), time.UTC)) - }) -} - -// A resident's programs list needs the classes one student is in. Essential Ed -// has no per-user endpoint, so it filters the inline students on each class. -func TestEssentialEdListUserCourses(t *testing.T) { - classes := []map[string]interface{}{ - { - "id": float64(69742), "active": float64(1), "name": "College Prep I", - "open_date": "2026-01-31 00:00:00", "close_date": nil, - "students": []interface{}{ - map[string]interface{}{"id": float64(4329946)}, - map[string]interface{}{"id": float64(4329947)}, - }, - }, - { - "id": float64(69743), "active": float64(1), "name": "High School Prep I", - "open_date": "2026-01-31 00:00:00", "close_date": nil, - "students": []interface{}{map[string]interface{}{"id": float64(4329946)}}, - }, - } - - tests := []struct { - name string - externalUserID string - wantNames []string - }{ - {"a student in both classes", "4329946", []string{"College Prep I", "High School Prep I"}}, - {"a student in one class", "4329947", []string{"College Prep I"}}, - {"a student in none", "4329948", []string{}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(envelope(classes, true)) - }) - defer done() - - reader, err := srv.newLiveProgramProvider(provider) - assert.NoError(t, err) - lister, ok := reader.(liveUserCourseLister) - assert.True(t, ok, "essential ed must report a user's own courses") - - courses, err := lister.ListUserCourses(context.Background(), tt.externalUserID) - assert.NoError(t, err) - names := make([]string, 0, len(courses)) - for _, course := range courses { - names = append(names, course.name) - } - assert.Equal(t, tt.wantNames, names) - }) - } -} - -// Canvas keeps its dedicated per-user endpoint rather than filtering a listing. -func TestCanvasDoesNotImplementLiveUserCourseLister(t *testing.T) { - srv := &server{} - reader, err := srv.newLiveProgramProvider(&providerplatforms.Provider{Type: providerplatforms.CanvasOSS}) - assert.NoError(t, err) - _, ok := reader.(liveUserCourseLister) - assert.False(t, ok) -} - -// The facility calendar in the main nav reads the same weekly pattern, so a -// provider with no calendar endpoint still shows up beside Canvas classes. -func TestWeeklyScheduleFacilityEvents(t *testing.T) { - march := func(day int) time.Time { return time.Date(2026, time.March, day, 0, 0, 0, 0, time.UTC) } - provider := &providerplatforms.Provider{ - Name: "Essential Education", - Type: providerplatforms.EssentialEd, - } - provider.ID = 2 - courses := []liveCourse{ - { - rawID: 69742, name: "College Prep I", startDt: march(1), status: Active, - weeklySchedule: map[time.Weekday][]meetingTime{time.Monday: {{540, 660}}}, - }, - {rawID: 69743, name: "High School Prep I", startDt: march(1), status: Active}, - { - // active=0 with no close_date: the pattern would otherwise run forever. - rawID: 69744, name: "Retired Class", startDt: march(1), status: Completed, - weeklySchedule: map[time.Weekday][]meetingTime{time.Monday: {{540, 660}}}, - }, - } - - events := weeklyScheduleFacilityEvents(provider, courses, march(2), march(15), time.UTC) - - assert.Len(t, events, 2, "two Mondays in the range; no events for the class with no schedule, none for the completed one") - assert.Equal(t, "College Prep I", events[0].ClassName) - assert.Equal(t, Active, events[0].ClassStatus, "the course's own status, not a hardcoded Active") - assert.Equal(t, "essential_ed", events[0].Source) - assert.True(t, events[0].IsCanvasEvent, "external events stay read-only on the calendar") - assert.Equal(t, encodeCanvasClassID(2, 69742), events[0].CohortID, "click-through lands on the class") - assert.Equal(t, march(2).Add(9*time.Hour), *events[0].StartTime) - assert.Equal(t, march(9).Add(9*time.Hour), *events[1].StartTime) -} - -// Only Essential Ed lists its own users. Canvas deliberately does not -- it -// keeps going through Provider Platforms' worker, so the user screens fall back -// for it. -func TestLiveUserListerFor(t *testing.T) { - tests := []struct { - name string - providerType providerplatforms.ProviderType - isLister bool - }{ - {"essential ed lists its own users", providerplatforms.EssentialEd, true}, - {"canvas oss goes through the worker", providerplatforms.CanvasOSS, false}, - {"canvas cloud goes through the worker", providerplatforms.CanvasCloud, false}, - {"a non-live provider has no lister", providerplatforms.Kolibri, false}, - } - srv := &server{} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, ok := srv.liveUserListerFor(&providerplatforms.Provider{Type: tt.providerType}) - assert.Equal(t, tt.isLister, ok) - }) - } -} - -// A 4xx body carries the reason; without it a caller only sees the status code. -func TestEssentialEdErrorIncludesResponseBody(t *testing.T) { - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message":"The roles parameter must be an array."}`)) - }) - defer done() - - transport := essentialEdTransport{srv: srv} - _, err := transport.fetchAll(context.Background(), provider, provider.BaseURL+"/users", 0) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "422") - assert.Contains(t, err.Error(), "must be an array") -} - -// Essential Ed has no calendar endpoint, so the class lists build their schedule -// string from the weekly pattern on the class record itself. -func TestWeeklyScheduleClassEvents(t *testing.T) { - chicago, err := time.LoadLocation("America/Chicago") - assert.NoError(t, err) - // 2026-03-04 is a Wednesday; the Monday that follows is 2026-03-09. - now := time.Date(2026, time.March, 4, 12, 0, 0, 0, time.UTC) - - type wantEvent struct { - rule string - duration string - } - tests := []struct { - name string - schedule map[time.Weekday][]meetingTime - loc *time.Location - want []wantEvent - }{ - { - name: "weekdays that meet at the same time share one rule, in week order", - schedule: map[time.Weekday][]meetingTime{ - time.Wednesday: {{540, 660}}, - time.Monday: {{540, 660}}, - }, - loc: time.UTC, - want: []wantEvent{ - {"DTSTART:20260309T090000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE", "2h0m0s"}, - }, - }, - { - name: "a weekday that meets at a different time keeps its own time and duration", - schedule: map[time.Weekday][]meetingTime{ - time.Wednesday: {{600, 690}}, - time.Monday: {{540, 660}}, - }, - loc: time.UTC, - want: []wantEvent{ - {"DTSTART:20260309T090000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO", "2h0m0s"}, - // Each rule anchors on its own next occurrence, and now is a Wednesday. - {"DTSTART:20260304T100000Z\nRRULE:FREQ=WEEKLY;BYDAY=WE", "1h30m0s"}, - }, - }, - { - name: "a second meeting on the same day is not dropped", - schedule: map[time.Weekday][]meetingTime{time.Monday: {{540, 660}, {840, 900}}}, - loc: time.UTC, - want: []wantEvent{ - {"DTSTART:20260309T090000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO", "2h0m0s"}, - {"DTSTART:20260309T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO", "1h0m0s"}, - }, - }, - { - name: "the pattern's wall clock is anchored in the facility's zone", - schedule: map[time.Weekday][]meetingTime{time.Monday: {{540, 660}}}, - loc: chicago, - want: []wantEvent{ - // 09:00 on 2026-03-09 is CDT, so 14:00Z -- DST began the day before. - {"DTSTART:20260309T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO", "2h0m0s"}, - }, - }, - { - name: "a class with no pattern reports no schedule", - schedule: nil, - loc: time.UTC, - want: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - course := liveCourse{rawID: 69742, name: "College Prep I", weeklySchedule: tt.schedule} - evs := weeklyScheduleClassEvents(course, tt.loc, now) - assert.Len(t, evs, len(tt.want)) - for i, want := range tt.want { - assert.Equal(t, want.rule, evs[i].RecurrenceRule) - assert.Equal(t, want.duration, evs[i].Duration) - } - }) - } -} - -// A resident's My Programs rows take the provider's own class status, not just -// the end date: Essential Ed marks a class finished with active=0 and frequently -// leaves close_date null. -func TestFetchLiveCoursesForUserHonoursProviderStatus(t *testing.T) { - tests := []struct { - name string - active float64 - closeDate interface{} - want ProgramEnrollmentStatus - }{ - {"active with no close date stays enrolled", 1, nil, Enrolled}, - {"inactive with no close date completes", 0, nil, EnrollmentCompleted}, - {"an elapsed close date still completes", 1, "2026-02-01 00:00:00", EnrollmentCompleted}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - classes := []map[string]interface{}{ - { - "id": float64(69742), "active": tt.active, "name": "College Prep I", - "open_date": "2026-01-31 00:00:00", "close_date": tt.closeDate, - "students": []interface{}{map[string]interface{}{"id": float64(4329946)}}, - }, - } - srv, provider, done := essentialEdServer(t, func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(envelope(classes, true)) - }) - defer done() - - rows := srv.fetchLiveCoursesForUser(provider, "4329946") - assert.Len(t, rows, 1) - assert.Equal(t, tt.want, rows[0].EnrollmentStatus) - }) - } -} - -// fakeProviders is Provider Platforms' api answering Provider from a map. -type fakeProviders struct { - providerplatforms.ProviderPlatforms - providers map[uint]providerplatforms.Provider -} - -func (f fakeProviders) Provider(_ context.Context, id uint) (providerplatforms.Provider, error) { - return f.providers[id], nil -} - -// Only a provider that lists its own users (Essential Ed) answers; the rest -// fall back to Provider Platforms' worker. -func TestLiveProvidersUsers(t *testing.T) { - status := http.StatusOK - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(envelope([]map[string]interface{}{ - {"id": float64(4329946), "first_name": "Jane", "last_name": "Smith", "username": "jsmith", "email_address": "jane@example.com"}, - }, true)) - })) - defer ts.Close() - const essentialEd, kolibri = 1, 2 - live := liveProviders{&server{ - Client: ts.Client(), - providerPlatforms: fakeProviders{providers: map[uint]providerplatforms.Provider{ - essentialEd: {ID: essentialEd, Type: providerplatforms.EssentialEd, Name: "Essential Ed", AccountID: "303", AccessKey: "token", BaseURL: ts.URL}, - kolibri: {ID: kolibri, Type: providerplatforms.Kolibri, Name: "Kolibri", BaseURL: ts.URL}, - }}, - }} - - t.Run("essential ed lists its users", func(t *testing.T) { - users, ok, err := live.Users(context.Background(), essentialEd) - require.NoError(t, err) - assert.True(t, ok) - assert.Equal(t, []coreapi.ProviderUser{{ - Username: "jsmith", NameFirst: "Jane", NameLast: "Smith", Email: "jane@example.com", - ExternalUserID: "4329946", ExternalUsername: "jsmith", - }}, users) - }) - - t.Run("kolibri goes through the worker", func(t *testing.T) { - users, ok, err := live.Users(context.Background(), kolibri) - require.NoError(t, err) - assert.False(t, ok) - assert.Nil(t, users) - }) - - t.Run("a failed listing is an error", func(t *testing.T) { - status = http.StatusBadGateway - _, ok, err := live.Users(context.Background(), essentialEd) - assert.Error(t, err) - assert.True(t, ok) - }) -} diff --git a/backend/src/modules/programs/events_attendance_handler.go b/backend/src/modules/programs/events_attendance_handler.go deleted file mode 100644 index 838135b39..000000000 --- a/backend/src/modules/programs/events_attendance_handler.go +++ /dev/null @@ -1,417 +0,0 @@ -package programs - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/teambition/rrule-go" -) - -// O(N+1) query problem in this method, but it is not a performance critical endpoint -// and the number of enrollments is usually small, so for now we're going to live with it. -func (srv *server) handleAddAttendanceForEvent(w http.ResponseWriter, r *http.Request, log sLog) error { - classId, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - class, err := srv.store.GetCohortByID(classId) - if err != nil { - return newDatabaseServiceError(err) - } - if class.CannotUpdateClass() { - return newBadRequestServiceError(err, "cannot perform action on class that is completed cancelled or archived") - } - eventID, err := strconv.Atoi(r.PathValue("event_id")) - if err != nil { - return newBadRequestServiceError(err, "event ID") - } - var attendances []ProgramClassEventAttendance - err = json.NewDecoder(r.Body).Decode(&attendances) - if err != nil { - return newJSONReqBodyServiceError(err) - } - const isoLayout = "2006-01-02" - now := time.Now().In(time.Local) - endOfToday := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, time.Local) - - args := getQueryContext(r) - overrides, err := srv.store.GetProgramClassEventOverrides(&args, []uint{uint(eventID)}...) - if err != nil { - return newDatabaseServiceError(err) - } - - uniqueUserIDs := make(map[uint]struct{}) - for i := range attendances { - if attendances[i].UserID != 0 { - uniqueUserIDs[attendances[i].UserID] = struct{}{} - } - } - - userIDSlice := make([]uint, 0, len(uniqueUserIDs)) - for uid := range uniqueUserIDs { - userIDSlice = append(userIDSlice, uid) - } - - enrolledIDs, err := srv.store.GetEnrolledUserIDsForClass(uint(classId), userIDSlice) - if err != nil { - return newDatabaseServiceError(err) - } - enrolledSet := make(map[uint]struct{}, len(enrolledIDs)) - for _, uid := range enrolledIDs { - enrolledSet[uid] = struct{}{} - } - event, err := srv.store.GetEventById(eventID) - if err != nil { - return newDatabaseServiceError(err) - } - for i := range attendances { - attendances[i].Note = NilIfBlank(FormatNullableString(attendances[i].Note)) - attendances[i].ReasonCategory = NilIfBlank(FormatNullableString(attendances[i].ReasonCategory)) - if attendances[i].Date == "" { - attendances[i].Date = time.Now().Format("2006-01-02") - } - - parsed, err := time.ParseInLocation(isoLayout, attendances[i].Date, time.Local) - if err != nil { - return newBadRequestServiceError(err, "unable to parse time") - } - - if parsed.After(endOfToday) { - return writeJsonResponse(w, http.StatusUnprocessableEntity, "attempted attendance date in future") - } - if isDateCancelled(overrides, attendances[i].Date) { - return writeJsonResponse(w, http.StatusConflict, "cannot record attendance for cancelled class date") - } - - if _, ok := enrolledSet[attendances[i].UserID]; !ok { - // just logging this since we may need to take past classes attendance - log.warnf("user %d is not enrolled in class %d", attendances[i].UserID, classId) - } - attendances[i].EventID = uint(eventID) - overrideForDate := findOverrideForDate(overrides, attendances[i].Date) - scheduledMinutes := deriveScheduledMinutes(event, overrideForDate) - if err := applyTimeTracking(&attendances[i], scheduledMinutes); err != nil { - return err - } - } - if err := srv.WithUserContext(r).LogUserAttendance(attendances, args.Ctx, &args.UserID, class.ClassName); err != nil { - return newDatabaseServiceError(err) - } - - return writeJsonResponse(w, http.StatusOK, "Attendance updated") -} - -func (srv *server) handleDeleteAttendee(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newBadRequestServiceError(err, "class ID") - } - class, err := srv.store.GetCohortByID(classID) - if err != nil { - return newDatabaseServiceError(err) - } - eventID, err := strconv.Atoi(r.PathValue("event_id")) - if err != nil { - return newBadRequestServiceError(err, "event ID") - } - userID, err := strconv.Atoi(r.PathValue("user_id")) - if err != nil { - return newBadRequestServiceError(err, "user ID") - } - date := r.URL.Query().Get("date") - if date == "" { - date = time.Now().Format("2006-01-02") - } - - args := getQueryContext(r) - overrides, err := srv.store.GetProgramClassEventOverrides(&args, []uint{uint(eventID)}...) - if err != nil { - return newDatabaseServiceError(err) - } - if isDateCancelled(overrides, date) { - return writeJsonResponse(w, http.StatusConflict, "cannot delete attendance for cancelled class date") - } - - var enrollment ProgramClassEnrollment - enrollmentErr := srv.store.Where("cohort_id = ? AND user_id = ? AND enrollment_status = ?", - classID, userID, Enrolled).First(&enrollment).Error - if enrollmentErr != nil { - return writeJsonResponse(w, http.StatusBadRequest, "user is not enrolled in class") - } - - rowsAffected, err := srv.WithUserContext(r).DeleteAttendance(eventID, userID, date, args.Ctx, &args.UserID, class.ClassName) - if err != nil { - return newDatabaseServiceError(err) - } - - if rowsAffected == 0 { - return writeJsonResponse(w, http.StatusBadRequest, "user is not enrolled in class or has no attendance record for this date") - } - - return writeJsonResponse(w, http.StatusNoContent, any(nil)) -} - -func (srv *server) handleGetEventAttendance(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newBadRequestServiceError(err, "class_id") - } - log.add("class id", classID) - eventID, err := strconv.Atoi(r.PathValue("event_id")) - if err != nil { - return newBadRequestServiceError(err, "event_id") - } - log.add("event id", eventID) - date := r.URL.Query().Get("date") - if date == "" { - date = time.Now().Format("2006-01-02") - } - log.add("date", date) - - args := getQueryContext(r) - - class, err := srv.store.GetCohortByID(classID) - if err != nil { - return newDatabaseServiceError(err) - } - includeCurrentlyEnrolled := class.Status != Active - - overrides, err := srv.store.GetProgramClassEventOverrides(&args, []uint{uint(eventID)}...) - if err != nil { - return newDatabaseServiceError(err) - } - if isDateCancelled(overrides, date) { //check overrides for cancelled date - return writePaginatedResponse(w, http.StatusConflict, []EnrollmentAttendance{}, args.IntoMeta()) - } - - combined, err := srv.store.GetEnrollmentsWithAttendanceForEvent(&args, classID, eventID, date, includeCurrentlyEnrolled) - if err != nil { - return newDatabaseServiceError(err) - } - return writePaginatedResponse(w, http.StatusOK, combined, args.IntoMeta()) -} - -func isDateCancelled(overrides []ProgramClassEventOverride, eventDate string) bool { - formatPattern := "2006-01-02" - evtDate, err := time.Parse(formatPattern, eventDate) ///making sure the date is good - if err != nil { - return false - } - - var isRescheduled bool - for _, override := range overrides { - rRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - continue - } - if len(rRule.All()) == 0 { - continue - } - overrideDate := rRule.All()[0].Format(formatPattern) - - if overrideDate == evtDate.Format(formatPattern) { - if override.IsCancelled { - if FormatNullableString(override.Reason) == "rescheduled" { - isRescheduled = true - break - } - return true // a true cancellation - } - } - } - - if isRescheduled { //check for reschedules on same date - for _, override := range overrides { - rRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - continue - } - if len(rRule.All()) == 0 { - continue - } - overrideDate := rRule.All()[0].Format(formatPattern) - if overrideDate == evtDate.Format(formatPattern) && !override.IsCancelled { //is it rescheduled on the same date?? - return false - } - } - return true - } - return false -} - -func findOverrideForDate(overrides []ProgramClassEventOverride, eventDate string) *ProgramClassEventOverride { - for i := range overrides { - rRule, err := rrule.StrToRRule(overrides[i].OverrideRrule) - if err != nil || len(rRule.All()) == 0 { - continue - } - if rRule.All()[0].Format("2006-01-02") == eventDate { - return &overrides[i] - } - } - return nil -} - -func deriveScheduledMinutes(event *ProgramClassEvent, override *ProgramClassEventOverride) int { - getMinutes := func(durationStr string) int { - duration, err := time.ParseDuration(durationStr) - if err != nil || duration <= 0 { - return 0 - } - return int(duration.Minutes()) - } - if override != nil && override.Duration != "" { - if mins := getMinutes(override.Duration); mins > 0 { - return mins - } - } - return getMinutes(event.Duration) -} - -func normalizeTimeValue(val *string) string { - if val == nil { - return "" - } - return strings.TrimSpace(*val) -} - -func applyTimeTracking(attendance *ProgramClassEventAttendance, scheduledMinutes int) error { - checkIn := normalizeTimeValue(attendance.CheckInAt) - checkOut := normalizeTimeValue(attendance.CheckOutAt) - - const timeLayout = "15:04" - var minutesAttended *int - - if checkIn == "" && checkOut == "" { - if attendance.AttendanceStatus == Present || attendance.AttendanceStatus == Partial { - mins := scheduledMinutes - minutesAttended = &mins - } - } else if checkIn != "" && checkOut == "" { - // need to allow saving an in-progress attendance with only a check-in time - attendance.CheckInAt = &checkIn - } else { - if checkIn == "" || checkOut == "" { - return newBadRequestServiceError(fmt.Errorf("invalid time range"), "check-in and check-out times are both required for time-based attendance") - } - parsedIn, err := time.Parse(timeLayout, checkIn) - if err != nil { - return newBadRequestServiceError(err, "check_in_at must be in HH:MM format") - } - parsedOut, err := time.Parse(timeLayout, checkOut) - if err != nil { - return newBadRequestServiceError(err, "check_out_at must be in HH:MM format") - } - diff := int(parsedOut.Sub(parsedIn).Minutes()) - if diff <= 0 { - return newBadRequestServiceError(fmt.Errorf("negative duration"), "check_out_at must be after check_in_at") - } - minutesAttended = &diff - if scheduledMinutes <= 0 { - scheduledMinutes = *minutesAttended - } - if *minutesAttended > scheduledMinutes { - minutes := scheduledMinutes - minutesAttended = &minutes - } - attendance.CheckInAt = &checkIn - attendance.CheckOutAt = &checkOut - } - - attendance.MinutesAttended = minutesAttended - attendance.ScheduledMinutes = &scheduledMinutes - - if minutesAttended == nil { - if attendance.AttendanceStatus == "" { - if checkIn != "" { - attendance.AttendanceStatus = Present - } else { - attendance.AttendanceStatus = Absent_Unexcused - } - } - if attendance.AttendanceStatus == Partial { - return newBadRequestServiceError(fmt.Errorf("missing minutes for partial attendance"), "partial attendance requires check-in and check-out times") - } - return nil - } - - minutes := *minutesAttended - - if attendance.AttendanceStatus == "" { - switch { - case minutes == 0: - attendance.AttendanceStatus = Absent_Unexcused - case scheduledMinutes > 0 && minutes < scheduledMinutes: - attendance.AttendanceStatus = Partial - default: - attendance.AttendanceStatus = Present - } - } - - if attendance.AttendanceStatus == Present && scheduledMinutes > 0 && minutes > 0 && minutes < scheduledMinutes { - attendance.AttendanceStatus = Partial - } - - if attendance.AttendanceStatus == Partial && minutes == 0 { - return newBadRequestServiceError(fmt.Errorf("missing minutes for partial attendance"), "partial attendance requires check-in and check-out times") - } - - return nil -} - -func (srv *server) handleGetAttendanceRateForEvent(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - eventID, err := strconv.Atoi(r.PathValue("event_id")) - if err != nil { - return newInvalidQueryParamServiceError(err, "event ID") - } - date := r.URL.Query().Get("date") - if date == "" { - date = time.Now().Format("2006-01-02") - } - attendanceRate, err := srv.store.GetAttendanceRateForEvent(r.Context(), eventID, classID, date) - if err != nil { - return newDatabaseServiceError(err) - } - response := map[string]float64{ - "attendance_rate": attendanceRate, - } - return writeJsonResponse(w, http.StatusOK, response) -} - -func (srv *server) handleGetHistoricalEnrollmentBatch(w http.ResponseWriter, r *http.Request, log sLog) error { - classID, err := strconv.Atoi(r.PathValue("cohort_id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - - datesParam := r.URL.Query().Get("dates") - if datesParam == "" { - return newBadRequestServiceError(nil, "dates parameter is required") - } - - dates := strings.Split(datesParam, ",") - if len(dates) == 0 { - return newBadRequestServiceError(nil, "at least one date must be provided") - } - - for _, date := range dates { - if _, err := time.Parse("2006-01-02", strings.TrimSpace(date)); err != nil { - return newBadRequestServiceError(err, "invalid date format: "+date) - } - } - - results, err := srv.store.GetHistoricalEnrollmentForDates(classID, dates) - if err != nil { - return newDatabaseServiceError(err) - } - - return writeJsonResponse(w, http.StatusOK, results) -} diff --git a/backend/src/modules/programs/events_attendance_store.go b/backend/src/modules/programs/events_attendance_store.go deleted file mode 100644 index 1604a0a0b..000000000 --- a/backend/src/modules/programs/events_attendance_store.go +++ /dev/null @@ -1,690 +0,0 @@ -package programs - -import ( - "context" - "fmt" - "net/url" - "slices" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/identity" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -func (db *Store) GetAttendees(queryParams *QueryContext, params url.Values, classId int) ([]ProgramClassEventAttendance, error) { - date := params.Get("date") - userId := params.Get("user_id") - eventId := params.Get("event_id") - tx := db.Model(&ProgramClassEventAttendance{}) - if date != "" { - tx = tx.Where("date = ?", date) - } - if userId != "" { - tx = tx.Where("user_id = ?", userId) - } - if eventId != "" { - tx = tx.Where("event_id = ?", eventId) - } - attendance := make([]ProgramClassEventAttendance, 0, queryParams.PerPage) - if err := tx.Count(&queryParams.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "class_event_attendance") - } - err := tx.Order(queryParams.OrderClause("program_class_event_attendance.created_at DESC")).Limit(queryParams.PerPage).Offset(queryParams.CalcOffset()).Find(&attendance).Error - if err != nil { - return nil, newGetRecordsDBError(err, "class_event_attendance") - } - return attendance, nil -} - -func (db *Store) LogUserAttendance(attendanceParams []ProgramClassEventAttendance, ctx context.Context, adminID *uint, className string) error { - var updateUserID uint - if ctx := db.Statement.Context; ctx != nil { - if c, ok := identity.FromContext(ctx); ok { - updateUserID = c.UserID - } - } - tx := db.Begin() - if tx.Error != nil { - return newDBError(tx.Error, "unable to start DB transaction") - } - - for _, att := range attendanceParams { - existingRow := updateUserID != 0 && att.ID != 0 - if existingRow { - att.UpdateUserID = &updateUserID - } - - // include deleted_at so re-marking a previously soft-deleted record revives it - // (excluded.deleted_at is NULL on insert) instead of updating a hidden, soft-deleted row - if err := tx. - Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "event_id"}, {Name: "user_id"}, {Name: "date"}}, - DoUpdates: clause.AssignmentColumns([]string{"attendance_status", "note", "reason_category", "check_in_at", "check_out_at", "minutes_attended", "scheduled_minutes", "update_user_id", "deleted_at"}), - }). - Create(&att).Error; err != nil { - tx.Rollback() - return newCreateDBError(err, "upserting attendance record") - } - - sessionDateParsed, err := time.ParseInLocation("2006-01-02", att.Date, time.Local) - if err != nil { - tx.Rollback() - return newDBError(err, "invalid session date format") - } - - history := NewUserAccountHistory(att.UserID, AttendanceRecorded, adminID, nil, nil) - history.AttendanceStatus = att.AttendanceStatus - history.ClassName = &className - history.SessionDate = &sessionDateParsed - - if err := tx.Create(history).Error; err != nil { - tx.Rollback() - return newCreateDBError(err, "user_account_history") - } - } - - if err := tx.Commit().Error; err != nil { - return newDBError(err, "unable to commit DB transaction") - } - return nil -} - -func (db *Store) DeleteAttendance(eventId, userId int, date string, ctx context.Context, adminID *uint, className string) (int64, error) { - if date == "" { - date = time.Now().Format("2006-01-02") - } - - tx := db.Begin().WithContext(ctx) - if tx.Error != nil { - return 0, newDBError(tx.Error, "unable to start DB transaction") - } - - result := tx.Where("event_id = ? AND user_id = ? AND date = ?", eventId, userId, date).Delete(&ProgramClassEventAttendance{}) - if result.Error != nil { - tx.Rollback() - return 0, newDeleteDBError(result.Error, "class_event_attendance") - } - - if result.RowsAffected > 0 { - sessionDateParsed, err := time.ParseInLocation("2006-01-02", date, time.Local) - if err != nil { - tx.Rollback() - return 0, newDBError(err, "invalid session date format") - } - - history := NewUserAccountHistory(uint(userId), AttendanceRecorded, adminID, nil, nil) - history.AttendanceStatus = Attendance("deleted") - history.ClassName = &className - history.SessionDate = &sessionDateParsed - - if err := tx.Create(history).Error; err != nil { - tx.Rollback() - return 0, newCreateDBError(err, "user_account_history") - } - } - - if err := tx.Commit().Error; err != nil { - return 0, newDBError(err, "unable to commit DB transaction") - } - - return result.RowsAffected, nil -} - -func (db *Store) GetEnrolledUserIDsForClass(classID uint, userIDs []uint) ([]uint, error) { - var enrolledIDs []uint - tx := db.Model(&ProgramClassEnrollment{}). - Where("cohort_id = ? AND enrollment_status = ?", classID, Enrolled) - if len(userIDs) > 0 { - tx = tx.Where("user_id IN ?", userIDs) - } - if err := tx.Pluck("user_id", &enrolledIDs).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - return enrolledIDs, nil -} - -func (db *Store) GetEnrollmentsWithAttendanceForEvent(qryCtx *QueryContext, classID, eventID int, date string, includeCurrentlyEnrolled bool) ([]EnrollmentAttendance, error) { - baseQuery := ` - FROM program_class_enrollments AS e - JOIN users AS u ON u.id = e.user_id - LEFT JOIN program_class_event_attendance AS a - ON a.user_id = e.user_id AND a.event_id = ? AND a.date = ? AND a.deleted_at IS NULL - WHERE e.cohort_id = ? - ` - - if includeCurrentlyEnrolled { - baseQuery += ` - AND ( - (DATE(e.enrolled_at) <= ?::date AND (e.enrollment_ended_at IS NULL OR DATE(e.enrollment_ended_at) >= ?::date)) - OR e.enrollment_status = 'Enrolled' - ) - ` - } else { - baseQuery += ` - AND DATE(e.enrolled_at) <= ?::date - AND (e.enrollment_ended_at IS NULL OR DATE(e.enrollment_ended_at) >= ?::date) - ` - } - - args := []any{eventID, date, classID, date, date} - - if qryCtx.Search != "" { - query := qryCtx.SearchQuery() - baseQuery += ` AND (LOWER(u.name_first) LIKE ? OR LOWER(u.name_last) LIKE ? OR LOWER(u.doc_id) LIKE ?)` - args = append(args, query, query, query) - } - - countQuery := "SELECT COUNT(*) " + baseQuery - if err := db.WithContext(qryCtx.Ctx).Raw(countQuery, args...).Scan(&qryCtx.Total).Error; err != nil { - return nil, err - } - - selectClause := ` - SELECT - e.id AS enrollment_id, - e.cohort_id AS cohort_id, - e.enrollment_status AS enrollment_status, - u.id AS user_id, - u.username AS username, - u.name_first AS name_first, - u.name_last AS name_last, - u.doc_id AS doc_id, - a.id AS attendance_id, - a.event_id AS event_id, - a.date AS date, - a.attendance_status AS attendance_status, - a.note AS note, - a.reason_category AS reason_category, - a.check_in_at AS check_in_at, - a.check_out_at AS check_out_at, - a.minutes_attended AS minutes_attended, - a.scheduled_minutes AS scheduled_minutes` - finalQuery := selectClause + baseQuery - if slices.Contains([]string{"name_first", "name_last"}, qryCtx.OrderBy) { - finalQuery += " ORDER BY " + adjustUserOrderBy(qryCtx.OrderClause("e.created_at DESC")) - } else { - finalQuery += " ORDER BY e.id ASC" - } - - finalQuery += " LIMIT ? OFFSET ?" - args = append(args, qryCtx.PerPage, qryCtx.CalcOffset()) - var results []EnrollmentAttendance - - newSession := db.Session(&gorm.Session{NewDB: true}) - if err := newSession.WithContext(qryCtx.Ctx).Raw(finalQuery, args...).Scan(&results).Error; err != nil { - return nil, err - } - return results, nil -} - -func (db *Store) GetAttendanceRateForEvent(ctx context.Context, eventID int, classID int, eventDate string) (float64, error) { - var attendanceRate float64 - sql := ` - select coalesce( - sum( - case - when pcea.attendance_status = 'present' then 1 - when pcea.attendance_status = 'partial' then least( - coalesce(pcea.minutes_attended, pcea.scheduled_minutes, 0)::numeric / - nullif(coalesce(pcea.scheduled_minutes, pcea.minutes_attended, 0), 0), - 1 - ) - else 0 - end - ) * 100.0 / - nullif( - (select count(*) from program_class_enrollments e - where e.cohort_id = ? - and e.enrolled_at <= ? - and (e.enrollment_ended_at IS NULL OR e.enrollment_ended_at >= ?)), - 0 - ), 0 - ) as attendance_percentage - from program_class_event_attendance pcea - inner join program_class_events pce ON pce.id = pcea.event_id - where pcea.event_id = ? AND pcea.date = ? AND pcea.deleted_at IS NULL` - - if err := db.WithContext(ctx).Raw(sql, classID, eventDate, eventDate, eventID, eventDate).Scan(&attendanceRate).Error; err != nil { - return 0, newNotFoundDBError(err, "program_class_event_attendance") - } - return attendanceRate, nil -} - -func (db *Store) GetAttendanceFlagsForClass(classID int, args *QueryContext) ([]AttendanceFlag, error) { - flags := make([]AttendanceFlag, 0, args.PerPage) - attendanceCoreSQL := `from program_class_enrollments e - inner join program_class_cohorts c on c.id = e.cohort_id - inner join users u on u.id = e.user_id - where c.id = ? - and e.enrollment_status = 'Enrolled' - and c.status = 'Active' - and u.deleted_at is null - and exists (select 1 from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id - and att.attendance_status is not null - )` - noAttendanceSQL := `and exists (select 1 from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id - and att.user_id = e.user_id - ) - and not exists (select 1 from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id - and att.user_id = e.user_id - and att.attendance_status in ('present','partial') - )` - multiAbsencesSQL := `and exists (select 1 from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id - and att.user_id = e.user_id - and att.attendance_status in ('present','partial') - ) - and (select count(*) from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id - and att.user_id = e.user_id - and att.attendance_status = 'absent_unexcused' - ) >= 3` - - statsSQL := `, - e.user_id, - (select count(*) from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id and att.user_id = e.user_id - ) as total_sessions, - (select count(*) from program_class_events evt - inner join program_class_event_attendance att on att.event_id = evt.id and att.deleted_at is null - where evt.cohort_id = c.id and att.user_id = e.user_id - and att.attendance_status in ('present','partial') - ) as attended_sessions` - - countQuery := fmt.Sprintf(`select count(*) from ( - select u.id %s %s - union all - select u.id %s %s - ) as attendance_flags`, attendanceCoreSQL, noAttendanceSQL, attendanceCoreSQL, multiAbsencesSQL) - - if err := db.WithContext(args.Ctx).Raw(countQuery, classID, classID).Scan(&args.Total).Error; err != nil { - return nil, newNotFoundDBError(err, "program_class_event_attendance") - } - - attendanceQuery := fmt.Sprintf(`select name_first, name_last, doc_id, flag_type, user_id, total_sessions, attended_sessions from ( - select u.name_first, u.name_last, - u.doc_id, 'no_attendance' as flag_type %s %s %s - union all - select u.name_first, u.name_last, - u.doc_id, 'multiple_absences' as flag_type %s %s %s - ) AS attendance_flags - ORDER BY name_last, name_first - LIMIT ? OFFSET ?`, statsSQL, attendanceCoreSQL, noAttendanceSQL, statsSQL, attendanceCoreSQL, multiAbsencesSQL) - if err := db.WithContext(args.Ctx).Raw(attendanceQuery, classID, classID, args.PerPage, args.CalcOffset()).Scan(&flags).Error; err != nil { - return nil, newNotFoundDBError(err, "program_class_event_attendance") - } - - for i := range flags { - flags[i].MissedSessions = flags[i].TotalSessions - flags[i].AttendedSessions - if flags[i].TotalSessions > 0 { - flags[i].AttendanceRate = int(float64(flags[i].AttendedSessions) / float64(flags[i].TotalSessions) * 100) - } - } - - if len(flags) > 0 { - if err := db.computeConsecutiveAbsences(classID, flags); err != nil { - return nil, err - } - } - - return flags, nil -} - -func (db *Store) computeConsecutiveAbsences(classID int, flags []AttendanceFlag) error { - userIDs := make([]uint, len(flags)) - for i, f := range flags { - userIDs[i] = f.UserID - } - - type record struct { - UserID uint `gorm:"column:user_id"` - Date string `gorm:"column:date"` - AttendanceStatus string `gorm:"column:attendance_status"` - } - var records []record - if err := db.Raw(` - SELECT att.user_id, att.date, att.attendance_status - FROM program_class_event_attendance att - INNER JOIN program_class_events evt ON evt.id = att.event_id - WHERE evt.cohort_id = ? AND att.user_id IN ? AND att.deleted_at IS NULL - ORDER BY att.user_id, att.date DESC`, - classID, userIDs, - ).Scan(&records).Error; err != nil { - return newNotFoundDBError(err, "consecutive absences") - } - - byUser := make(map[uint]int) - var currentUser uint - var counting bool - for _, r := range records { - if r.UserID != currentUser { - currentUser = r.UserID - counting = true - byUser[currentUser] = 0 - } - if !counting { - continue - } - switch r.AttendanceStatus { - case "present", "partial": - counting = false - case "absent_excused", "absent_unexcused": - byUser[currentUser]++ - } - } - - for i := range flags { - flags[i].ConsecutiveAbsences = byUser[flags[i].UserID] - } - return nil -} - -func (db *Store) GetMissingAttendance(classID int, args *QueryContext) (int, error) { - var events []ProgramClassEvent - err := db.WithContext(args.Ctx). - Preload("Class"). - Where("cohort_id = ?", classID). - Find(&events).Error - if err != nil { - return 0, newNotFoundDBError(err, "program_class_events") - } - allEventDates := make([]EventInstance, 0, 20) - if len(events) == 1 { - allEventDates = generateEventInstances(events[0], events[0].Cohort.StartDt, time.Now().AddDate(0, 0, 1)) // adds one day to today so today is included - } else { - for _, event := range events { - startDate := event.Cohort.StartDt - endDate := time.Now().AddDate(0, 0, 1) // adds one day to today so today is included - if event.Cohort.EndDt != nil && event.Cohort.EndDt.Before(time.Now()) { //if it has an end date that is before today, then use that - endDate = *event.Cohort.EndDt - } - eventInstances := generateEventInstances(event, startDate, endDate) - allEventDates = append(allEventDates, eventInstances...) - } - } - totalEvents := len(allEventDates) - - var students []ProgramClassEnrollment - err = db.WithContext(args.Ctx). - Where("cohort_id = ?", classID). - Find(&students).Error - if err != nil { - return 0, newGetRecordsDBError(err, "program_class_enrollments") - } - - type AttendanceDateCount struct { - EventID int `json:"event_id"` - Date string `json:"date"` - Count int64 `json:"count"` - } - - var attendanceCounts []AttendanceDateCount - eventIDs := make([]uint, len(allEventDates)) - dates := make([]string, len(allEventDates)) - - for i, eventDate := range allEventDates { - eventIDs[i] = eventDate.EventID - dates[i] = eventDate.StartTime.Format("2006-01-02") - } - - err = db.WithContext(args.Ctx). - Model(&ProgramClassEventAttendance{}). - Select("event_id, date, COUNT(*) as count"). - Where("event_id IN ? AND date IN ?", eventIDs, dates). - Group("event_id, date"). - Scan(&attendanceCounts).Error - if err != nil { - return 0, newGetRecordsDBError(err, "program_class_event_attendance") - } - - attendanceMap := make(map[string]int64) - for _, attendance := range attendanceCounts { - key := fmt.Sprintf("%d_%s", attendance.EventID, attendance.Date) - attendanceMap[key] = attendance.Count - } - - completedAttendanceDates := 0 - for _, eventDate := range allEventDates { - // count enrolled students on this specific date - enrolledCountOnDate := 0 - for _, student := range students { - if student.EnrolledAt == nil { - continue - } - if !student.EnrolledAt.After(eventDate.StartTime) && // enrolled before or on this date - (student.EnrollmentEndedAt == nil || student.EnrollmentEndedAt.After(eventDate.StartTime.AddDate(0, 0, -1))) { // not ended before this date - enrolledCountOnDate++ - } - } - - key := fmt.Sprintf("%d_%s", eventDate.EventID, eventDate.StartTime.Format("2006-01-02")) - attendanceCount, exists := attendanceMap[key] - if !exists { - attendanceCount = 0 - } - - if int(attendanceCount) == enrolledCountOnDate { - completedAttendanceDates++ - } - } - missingAttendanceCount := totalEvents - completedAttendanceDates - return missingAttendanceCount, nil -} - -func (db *Store) GetActiveClassesForMissingAttendance(args *QueryContext, facilityID *uint) ([]MissingAttendanceClass, error) { - var missClasses []MissingAttendanceClass - classQuery := db.WithContext(args.Ctx). - Table("program_class_cohorts c"). - Select("c.id, cl.name as name, f.name as facility_name, c.facility_id"). - Joins("JOIN program_classes cl ON cl.id = c.class_id"). - Joins("JOIN facilities f ON f.id = c.facility_id"). - Where("c.status = ?", Active). - Where("c.archived_at IS NULL") - if facilityID != nil { - classQuery = classQuery.Where("c.facility_id = ?", *facilityID) - } - if err := classQuery.Find(&missClasses).Error; err != nil { - return nil, newGetRecordsDBError(err, "program class cohort") - } - return missClasses, nil -} - -func (db *Store) GetActiveClassesWithEvents(args *QueryContext, facilityID *uint) ([]ProgramClassCohort, error) { - var classes []ProgramClassCohort - // The join supplies class_name, which is the only name a cohort has. Both tables - // carry status/archived_at/facility_id, so EVERY predicate below is table-qualified: - // unqualified, Postgres errors "ambiguous" while SQLite silently picks one. - classQuery := db.WithContext(args.Ctx). - Model(&ProgramClassCohort{}). - Select("program_class_cohorts.*, pc.name AS class_name"). - Joins("JOIN program_classes pc ON pc.id = program_class_cohorts.class_id"). - Preload("Events.Overrides"). - Preload("Events.RoomRef"). - Preload("Events.Instructor"). - Preload("Facility"). - Where("program_class_cohorts.status = ?", Active). - Where("program_class_cohorts.archived_at IS NULL") - if facilityID != nil { - classQuery = classQuery.Where("program_class_cohorts.facility_id = ?", *facilityID) - } - if err := classQuery.Find(&classes).Error; err != nil { - return nil, newGetRecordsDBError(err, "program class cohort") - } - return classes, nil -} - -func (db *Store) GetClassEventsWithOverrides(args *QueryContext, classIDs []uint) ([]ProgramClassEvent, error) { - var events []ProgramClassEvent - if err := db.WithContext(args.Ctx). - Model(&ProgramClassEvent{}). - Preload("Overrides"). - Preload("RoomRef"). - Where("cohort_id IN ?", classIDs). - Find(&events).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - return events, nil -} - -type AttendanceCount struct { - EventID uint `json:"event_id"` - Date string `json:"date"` - Count int64 `json:"count"` -} - -func (db *Store) GetAttendanceCountsForEvents(args *QueryContext, eventIDs []uint, dates []string) ([]AttendanceCount, error) { - var attendanceCounts []AttendanceCount - if err := db.WithContext(args.Ctx). - Model(&ProgramClassEventAttendance{}). - Select("event_id, date, COUNT(*) as count"). - Where("event_id IN ? AND date IN ?", eventIDs, dates). - Group("event_id, date"). - Scan(&attendanceCounts).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_event_attendance") - } - return attendanceCounts, nil -} - -type ClassEnrollmentCount struct { - CohortID uint `json:"cohort_id"` - Count int64 `json:"count"` -} - -// GetActiveEnrollmentCountsForClasses returns the number of currently-enrolled -// residents per class, keyed by class_id. Classes with no active enrollments -// are absent from the map (callers treat a missing key as zero). -func (db *Store) GetActiveEnrollmentCountsForClasses(args *QueryContext, classIDs []uint) (map[uint]int, error) { - var rows []ClassEnrollmentCount - if err := db.WithContext(args.Ctx). - Model(&ProgramClassEnrollment{}). - Select("cohort_id, COUNT(*) as count"). - Where("cohort_id IN ? AND enrollment_status = ?", classIDs, Enrolled). - Group("cohort_id"). - Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - counts := make(map[uint]int, len(rows)) - for _, row := range rows { - counts[row.CohortID] = int(row.Count) - } - return counts, nil -} - -func (db *Store) GetActiveEnrollmentsForClasses(args *QueryContext, classIDs []uint) ([]ProgramClassEnrollment, error) { - var enrollments []ProgramClassEnrollment - if err := db.WithContext(args.Ctx). - Model(&ProgramClassEnrollment{}). - Select("cohort_id, enrolled_at, enrollment_ended_at"). - Where("cohort_id IN ?", classIDs). - Where("enrollment_status = ?", Enrolled). - Find(&enrollments).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - return enrollments, nil -} - -func (db *Store) CreateAttendanceAuditTrail(ctx context.Context, att *ProgramClassEventAttendance, adminID *uint, className string) error { - - sessionDateParsed, err := time.ParseInLocation("2006-01-02", att.Date, time.Local) - if err != nil { - return newDBError(err, "invalid session date format") - } - - history := NewUserAccountHistory(att.UserID, AttendanceRecorded, adminID, nil, nil) - history.AttendanceStatus = att.AttendanceStatus - history.ClassName = &className - history.SessionDate = &sessionDateParsed - - if err := db.WithContext(ctx).Create(history).Error; err != nil { - return newCreateDBError(err, "user_account_history") - } - - return nil -} - -func (db *Store) GetCumulativeAttendanceRateForClass(ctx context.Context, classID int) (*float64, error) { - var attendanceRate *float64 - today := time.Now().Format("2006-01-02") - sql := ` - WITH attendance_credits AS ( - SELECT - pcea.id, - CASE - WHEN pcea.attendance_status = 'present' THEN 1.0 - WHEN pcea.attendance_status = 'partial' THEN LEAST( - COALESCE(pcea.minutes_attended, pcea.scheduled_minutes, 0)::numeric / - NULLIF(COALESCE(pcea.scheduled_minutes, pcea.minutes_attended, 0), 0), - 1 - ) - ELSE 0 - END as credit - FROM program_class_event_attendance pcea - INNER JOIN program_class_events pce ON pce.id = pcea.event_id - WHERE pce.cohort_id = ? AND pcea.date <= ? AND pcea.deleted_at IS NULL - ) - SELECT - (SELECT SUM(credit) FROM attendance_credits) * 100.0 / - NULLIF((SELECT COUNT(*) FROM attendance_credits), 0) - as attendance_percentage` - - if err := db.WithContext(ctx).Raw(sql, classID, today).Scan(&attendanceRate).Error; err != nil { - return nil, newNotFoundDBError(err, "program_class_event_attendance") - } - return attendanceRate, nil -} - -func (db *Store) GetCumulativeAttendanceRatesForClasses(ctx context.Context, classIDs []uint) (map[uint]float64, error) { - if len(classIDs) == 0 { - return map[uint]float64{}, nil - } - today := time.Now().Format("2006-01-02") - sql := ` - WITH attendance_credits AS ( - SELECT - pce.cohort_id, - CASE - WHEN pcea.attendance_status = 'present' THEN 1.0 - WHEN pcea.attendance_status = 'partial' THEN LEAST( - COALESCE(pcea.minutes_attended, pcea.scheduled_minutes, 0)::numeric / - NULLIF(COALESCE(pcea.scheduled_minutes, pcea.minutes_attended, 0), 0), - 1 - ) - ELSE 0 - END as credit - FROM program_class_event_attendance pcea - INNER JOIN program_class_events pce ON pce.id = pcea.event_id - WHERE pce.cohort_id IN ? AND pcea.date <= ? AND pcea.deleted_at IS NULL - ) - SELECT cohort_id, - COALESCE( - SUM(credit) * 100.0 / NULLIF(COUNT(*), 0), - 0 - ) as attendance_percentage - FROM attendance_credits - GROUP BY cohort_id` - - type row struct { - ClassID uint `gorm:"column:cohort_id"` - AttendanceRate float64 `gorm:"column:attendance_percentage"` - } - rows := []row{} - if err := db.WithContext(ctx).Raw(sql, classIDs, today).Scan(&rows).Error; err != nil { - return nil, newNotFoundDBError(err, "program_class_event_attendance") - } - - out := make(map[uint]float64, len(classIDs)) - for _, r := range rows { - out[r.ClassID] = r.AttendanceRate - } - return out, nil -} diff --git a/backend/src/modules/programs/facilities_handler.go b/backend/src/modules/programs/facilities_handler.go deleted file mode 100644 index dc429b1a9..000000000 --- a/backend/src/modules/programs/facilities_handler.go +++ /dev/null @@ -1,53 +0,0 @@ -package programs - -import ( - "encoding/json" - "net/http" - "strconv" -) - -func (srv *server) handleGetRooms(w http.ResponseWriter, r *http.Request, log sLog) error { - facilityID := facilityScopedQueryContext(r).FacilityID - log.add("facility_id", facilityID) - rooms, err := srv.store.GetRoomsForFacility(facilityID) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, rooms) -} - -func (srv *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, log sLog) error { - facilityID, err := requireFacilityID(r) - if err != nil { - return err - } - var room Room - if err := json.NewDecoder(r.Body).Decode(&room); err != nil { - return newJSONReqBodyServiceError(err) - } - room.FacilityID = facilityID - log.add("facility_id", facilityID) - log.add("room_name", room.Name) - created, err := srv.store.CreateRoom(&room) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusCreated, created) -} - -/** -* GET: /api/facilities/{facilityId}/instructors - */ -func (srv *server) handleGetFacilityInstructors(w http.ResponseWriter, r *http.Request, log sLog) error { - facilityId, err := strconv.Atoi(r.PathValue("facilityId")) - if err != nil { - return newInvalidIdServiceError(err, "facility ID") - } - - instructors, err := srv.store.GetFacilityInstructors(facilityId) - if err != nil { - return newDatabaseServiceError(err) - } - - return writeJsonResponse(w, http.StatusOK, instructors) -} diff --git a/backend/src/modules/programs/handlers.go b/backend/src/modules/programs/handlers.go deleted file mode 100644 index aeeb3d234..000000000 --- a/backend/src/modules/programs/handlers.go +++ /dev/null @@ -1,260 +0,0 @@ -package programs - -import ( - "fmt" - "net/http" - "slices" - "strconv" - - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - - "github.com/UnlockedLabs/unlocked-contracts/features" - "github.com/UnlockedLabs/unlocked-contracts/identity" - "github.com/UnlockedLabs/unlocked-contracts/users" -) - -// routes is every Programs route, with the access checks and features it had -// in core (EN-194). -func (srv *server) routes() []module.Route { - prog := []features.Access{features.ProgramManagement} - cohort := srv.inFacility(TableNameCohort, "cohort_id") - class := srv.inFacility(TableNameClass, "id") - return []module.Route{ - // Programs - {Pattern: "GET /api/programs", Handler: handle(srv.handleIndexPrograms), Features: prog}, - {Pattern: "GET /api/programs/filters", Handler: handle(srv.handleGetProgramFilters), Features: prog}, - {Pattern: "GET /api/programs/{id}", Handler: handle(srv.handleShowProgram), Features: prog}, - {Pattern: "GET /api/programs/detailed-list", Handler: handle(srv.handleIndexProgramsOverviewTable), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/programs/stats", Handler: handle(srv.handleIndexProgramsFacilitiesStats), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/programs/{id}/history", Handler: handle(srv.handleGetProgramHistory), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/programs/{id}/archive-check", Handler: handle(srv.handleGetProgramArchiveCheck), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/programs/{id}/delete-check", Handler: handle(srv.handleGetProgramDeleteCheck), Access: module.Admin, Features: prog}, - {Pattern: "POST /api/programs", Handler: handle(srv.handleCreateProgram), Access: module.Admin, Features: prog}, - {Pattern: "DELETE /api/programs/{id}", Handler: handle(srv.handleDeleteProgram), Access: module.Admin, Features: prog}, - {Pattern: "PATCH /api/programs/{id}/status", Handler: handle(srv.handleUpdateProgramStatus), Access: module.Admin, Features: prog}, - {Pattern: "PATCH /api/programs/{id}", Handler: handle(srv.handleUpdateProgram), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/programs/csv", Handler: handle(srv.handleExportProgramCSV), Access: module.Admin, Allow: deptAdminForAll, Features: prog}, - - // Classes - {Pattern: "GET /api/programs/{id}/classes", Handler: handle(srv.handleGetClassesForProgram), Features: prog}, - {Pattern: "GET /api/program-classes", Handler: handle(srv.handleIndexClassesForFacility), Features: prog}, - {Pattern: "POST /api/programs/{program_id}/classes", Handler: handle(srv.handleCreateClass), Access: module.Admin, Allow: srv.programInFacility("is_active = true AND archived_at IS NULL AND"), Features: prog}, - {Pattern: "GET /api/instructors/{id}/classes", Handler: handle(srv.handleGetClassesByInstructor), Access: module.Admin, UserParam: "id", Features: prog}, - {Pattern: "POST /api/program-classes/bulk-cancel", Handler: handle(srv.handleBulkCancelSessions), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}", Handler: handle(srv.handleGetClass), Allow: srv.classInFacility, Features: prog}, - {Pattern: "GET /api/programs/{program_id}/classes/outcomes", Handler: handle(srv.handleGetProgramClassOutcomes), Access: module.Admin, Allow: srv.programInFacility(""), Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/attendance-flags", Handler: handle(srv.handleGetAttendanceFlagsForClass), Access: module.Admin, Allow: srv.classInFacility, Features: prog}, - {Pattern: "GET /api/program-classes/missing-attendance", Handler: handle(srv.handleGetMissingAttendanceForFacility), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/missing-attendance", Handler: handle(srv.handleGetMissingAttendance), Access: module.Admin, Allow: srv.classInFacility, Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/attendance-rate", Handler: handle(srv.handleGetCumulativeAttendanceRate), Access: module.Admin, Allow: srv.classInFacility, Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/history", Handler: handle(srv.handleGetClassHistory), Access: module.Admin, Allow: srv.classInFacility, Features: prog}, - {Pattern: "PATCH /api/program-classes", Handler: handle(srv.handleUpdateClasses), Access: module.Admin, Allow: srv.classesInFacility, Features: prog}, - {Pattern: "PATCH /api/programs/{id}/classes/{cohort_id}", Handler: handle(srv.handleUpdateClass), Access: module.Admin, Allow: srv.classInFacility, Features: prog}, - {Pattern: "DELETE /api/program-classes/{cohort_id}", Handler: handle(srv.handleDeleteClass), Access: module.Admin, Allow: srv.classInFacility, Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/delete-check", Handler: handle(srv.handleGetClassDeleteCheck), Access: module.Admin, Allow: srv.classInFacility, Features: prog}, - - // The class tier - {Pattern: "GET /api/classes", Handler: handle(srv.handleIndexProgramClasses), Features: prog}, - {Pattern: "GET /api/classes/{id}", Handler: handle(srv.handleGetProgramClass), Allow: class, Features: prog}, - {Pattern: "POST /api/classes", Handler: handle(srv.handleCreateProgramClass), Access: module.Admin, Features: prog}, - {Pattern: "PATCH /api/classes/{id}", Handler: handle(srv.handleUpdateProgramClass), Access: module.Admin, Allow: class, Features: prog}, - - // Class events - {Pattern: "GET /api/student-calendar", Handler: handle(srv.handleGetStudentCalendar), Allow: residentPrograms, Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/events", Handler: handle(srv.handleGetProgramClassEvents), Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/canvas-schedule", Handler: handle(srv.handleGetCanvasClassSchedule), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/admin-calendar", Handler: handle(srv.handleGetAdminCalendar), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/program-classes/todays-schedule", Handler: handle(srv.handleGetTodaysSchedule), Access: module.Admin, Features: prog}, - {Pattern: "PUT /api/program-classes/{cohort_id}/events/{event_id}", Handler: handle(srv.handleEventOverrides), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "PATCH /api/program-classes/{cohort_id}/events/{event_id}", Handler: handle(srv.handlePatchEventOverride), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "DELETE /api/program-classes/{cohort_id}/events/{event_override_id}", Handler: handle(srv.handleDeleteEventOverride), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "POST /api/program-classes/{cohort_id}/events/{event_override_id}/uncancel", Handler: handle(srv.handleUncancelOverride), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "POST /api/program-classes/{cohort_id}/events/{event_id}/uncancel-series", Handler: handle(srv.handleUncancelSeries), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "POST /api/program-classes/{cohort_id}/events", Handler: handle(srv.handleCreateEvent), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "PUT /api/program-classes/{cohort_id}/events", Handler: handle(srv.handleRescheduleEventSeries), Access: module.Admin, Allow: cohort, Features: prog}, - - // Enrollments - {Pattern: "GET /api/program-classes/{cohort_id}/enrollments", Handler: handle(srv.handleGetEnrollmentsForProgram), Access: module.Admin, Features: prog}, - {Pattern: "POST /api/program-classes/{cohort_id}/enrollment-conflicts", Handler: handle(srv.handleCheckEnrollmentConflicts), Access: module.Admin, Features: prog}, - {Pattern: "POST /api/program-classes/{cohort_id}/enrollments", Handler: handle(srv.handleEnrollUsersInClass), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "PATCH /api/program-classes/{cohort_id}/enrollments", Handler: handle(srv.handleUpdateProgramClassEnrollments), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "PATCH /api/program-classes/{cohort_id}/enrollments/{enrollment_id}/date", Handler: handle(srv.handleUpdateEnrollmentDate), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "DELETE /api/programs/{id}/classes/{cohort_id}/enrollments", Handler: handle(srv.handleDeleteProgramClassEnrollments), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "GET /api/programs/{id}/classes/{cohort_id}/enrollments/{enrollment_id}/attendance", Handler: handle(srv.handleGetProgramClassEnrollmentsAttendance), Access: module.Admin, Allow: cohort, Features: prog}, - {Pattern: "GET /api/users/{id}/program-completions", Handler: handle(srv.handleGetUserProgramCompletions), UserParam: "id", Features: prog}, - - // Attendance - {Pattern: "GET /api/program-classes/{cohort_id}/events/{event_id}/attendance", Handler: handle(srv.handleGetEventAttendance), Access: module.Admin, Allow: srv.attendanceInFacility, Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/events/{event_id}/attendance-rate", Handler: handle(srv.handleGetAttendanceRateForEvent), Access: module.Admin, Allow: srv.attendanceInFacility, Features: prog}, - {Pattern: "GET /api/program-classes/{cohort_id}/historical-enrollment-batch", Handler: handle(srv.handleGetHistoricalEnrollmentBatch), Access: module.Admin, Allow: srv.attendanceInFacility, Features: prog}, - {Pattern: "POST /api/program-classes/{cohort_id}/events/{event_id}/attendance", Handler: handle(srv.handleAddAttendanceForEvent), Access: module.Admin, Allow: srv.attendanceInFacility, Features: prog}, - {Pattern: "DELETE /api/program-classes/{cohort_id}/events/{event_id}/attendance/{user_id}", Handler: handle(srv.handleDeleteAttendee), Access: module.Admin, Allow: srv.attendanceInFacility, Features: prog}, - - // Reports - {Pattern: "POST /api/reports/generate", Handler: handle(srv.handleGenerateReport), Access: module.Admin, Features: prog}, - - // Rooms and instructors - {Pattern: "GET /api/rooms", Handler: handle(srv.handleGetRooms), Access: module.Admin, Features: prog}, - {Pattern: "POST /api/rooms", Handler: handle(srv.handleCreateRoom), Access: module.Admin, Features: prog}, - {Pattern: "GET /api/facilities/{facilityId}/instructors", Handler: handle(srv.handleGetFacilityInstructors), Access: module.Admin, Allow: facilityInPath("facilityId"), Features: prog}, - - // A user's programs and attendance - {Pattern: "GET /api/users/{id}/programs", Handler: handle(srv.handleGetUserPrograms), UserParam: "id", Allow: residentPrograms}, - {Pattern: "GET /api/users/{id}/attendance-trend", Handler: handle(srv.handleGetUserAttendanceTrend), UserParam: "id"}, - {Pattern: "GET /api/users/{id}/attendance-export", Handler: handle(srv.handleExportResidentAttendanceCSV), Access: module.Admin, Allow: srv.userInPath("id")}, - } -} - -// The access checks below are core's resolvers for these routes, unchanged -// (EN-194). A check that fails answers 401, as core's did. - -// inFacility passes admins who can switch facility, and anyone else whose -// facility is the facility_id of the table's row whose id is the path -// parameter. -func (srv *server) inFacility(table, param string) func(*http.Request, *identity.Claims) bool { - return func(r *http.Request, claims *identity.Claims) bool { - if claims.CanSwitchFacility() { - return true - } - var facID uint - err := srv.store.Table(table). - Select("facility_id"). - Where("id = ?", r.PathValue(param)). - Limit(1).Scan(&facID).Error - return err == nil && claims.FacilityID == facID - } -} - -// facilityInPath passes admins who can switch facility, and anyone else whose -// facility is the path parameter. -func facilityInPath(param string) func(*http.Request, *identity.Claims) bool { - return func(r *http.Request, claims *identity.Claims) bool { - if claims.CanSwitchFacility() { - return true - } - facID, err := strconv.ParseUint(r.PathValue(param), 10, 64) - return err == nil && claims.FacilityID == uint(facID) - } -} - -// userInPath passes admins who can switch facility, the user in the path -// parameter, and admins at that user's facility. -func (srv *server) userInPath(param string) func(*http.Request, *identity.Claims) bool { - return func(r *http.Request, claims *identity.Claims) bool { - if claims.CanSwitchFacility() { - return true - } - id, err := strconv.Atoi(r.PathValue(param)) - if err != nil { - return false - } - if claims.UserID == uint(id) { - return true - } - if !slices.Contains(users.AdminRoles, claims.Role) { - return false - } - user, err := srv.store.GetUserByID(uint(id)) - return err == nil && user.FacilityID == claims.FacilityID - } -} - -// residentPrograms passes admins, and residents whose facility has resident -// programs turned on. -func residentPrograms(_ *http.Request, claims *identity.Claims) bool { - if slices.Contains(users.AdminRoles, claims.Role) { - return true - } - return claims.HasFeature(features.ResidentPrograms) -} - -// deptAdminForAll passes admins, except that only admins who can switch -// facility may ask for ?all=true. -func deptAdminForAll(r *http.Request, claims *identity.Claims) bool { - all := r.URL.Query().Get("all") == "true" - if all && !claims.CanSwitchFacility() { - return false - } - return claims.IsAdmin() -} - -// classInFacility is inFacility for the class in cohort_id, which may also be -// a Canvas class: a facility-scoped Canvas ID carries its facility, and an -// old-style one is scoped by the handler. -func (srv *server) classInFacility(r *http.Request, claims *identity.Claims) bool { - if claims.CanSwitchFacility() { - return true - } - id, err := strconv.ParseUint(r.PathValue("cohort_id"), 10, 64) - if err != nil { - return false - } - // Facility-scoped canvas ID: assert the embedded facility matches the JWT. - if uint(id) >= CanvasFacilityClassIDOffset { - embeddedFacilityID, _, _ := decodeFacilityCanvasClassID(uint(id)) - return claims.FacilityID == embeddedFacilityID - } - // Old-style canvas ID: allow through; handlers scope data via JWT claims. - if uint(id) >= CanvasClassIDOffset { - return true - } - var facID uint - return srv.store.Table("program_class_cohorts"). - Select("facility_id"). - Where("id = ?", id). - Limit(1).Scan(&facID).Error == nil && claims.FacilityID == facID -} - -// attendanceInFacility is inFacility for the class in cohort_id, letting any -// Canvas class through. -func (srv *server) attendanceInFacility(r *http.Request, claims *identity.Claims) bool { - if claims.CanSwitchFacility() { - return true - } - id, err := strconv.ParseUint(r.PathValue("cohort_id"), 10, 64) - if err != nil { - return false - } - if uint(id) >= CanvasClassIDOffset { - return true - } - var facID uint - return srv.store.Table("program_class_cohorts"). - Select("facility_id"). - Where("id = ?", id). - Limit(1).Scan(&facID).Error == nil && claims.FacilityID == facID -} - -// classesInFacility passes when a class in the ?id= list is at the caller's -// facility. -func (srv *server) classesInFacility(r *http.Request, claims *identity.Claims) bool { - var programClass ProgramClassCohort - err := srv.store.WithContext(r.Context()). - Table("program_class_cohorts"). - Select("facility_id"). - Where("id IN (?)", r.URL.Query()["id"]). - Where("facility_id = ?", claims.FacilityID). - First(&programClass).Error - return err == nil -} - -// programInFacility passes when the program_id in the path is offered at the -// request's facility and matches check. A statewide admin (facility 0) passes -// only when there is no check, for facility-agnostic reads. -func (srv *server) programInFacility(check string) func(*http.Request, *identity.Claims) bool { - return func(r *http.Request, claims *identity.Claims) bool { - // Facility is the request's, as getQueryContext has it: the - // ?facility_id= param for switch-capable admins, else their home - // facility. - facilityID := requestFacilityID(r, claims) - if facilityID == 0 { - return check == "" - } - var count int64 - return srv.store.WithContext(r.Context()). - Table("programs"). - Where(fmt.Sprintf("id = ? AND %s id IN (SELECT program_id FROM facilities_programs WHERE facility_id = ?)", check), - r.PathValue("program_id"), facilityID). - Count(&count).Error == nil && count > 0 - } -} diff --git a/backend/src/modules/programs/helpers_test.go b/backend/src/modules/programs/helpers_test.go deleted file mode 100644 index 58024ac01..000000000 --- a/backend/src/modules/programs/helpers_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package programs - -import ( - "fmt" - "strings" - "testing" - "time" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -// newTestDB returns an empty in-memory database with Programs' tables and the -// rows of core's tables that Programs reads and writes. -func newTestDB(t *testing.T) *gorm.DB { - t.Helper() - dsn := fmt.Sprintf("file:%s_%d?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"), time.Now().UnixNano()) - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true}) - if err != nil { - t.Fatal(err) - } - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = sqlDB.Close() }) - core := []any{&Facility{}, &User{}, &UserAccountHistory{}} - if err := db.AutoMigrate(append(core, Module{}.TestModels()...)...); err != nil { - t.Fatal(err) - } - return db -} - -// newTestServer returns a Programs server on an empty test database. -func newTestServer(t *testing.T) *server { - t.Helper() - return &server{store: &Store{newTestDB(t)}} -} diff --git a/backend/src/modules/programs/http.go b/backend/src/modules/programs/http.go deleted file mode 100644 index 310cacfa9..000000000 --- a/backend/src/modules/programs/http.go +++ /dev/null @@ -1,571 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "math" - "net/http" - "net/url" - "reflect" - "slices" - "strconv" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - - "github.com/UnlockedLabs/unlocked-contracts/features" - "github.com/UnlockedLabs/unlocked-contracts/identity" - "github.com/go-playground/validator/v10" - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// The response, error, paging and request helpers Programs' handlers use. -// They are copies of core's (EN-188: handler helpers stay in each module), so -// every response is the same JSON as before the move (EN-194). - -type resource[T any] struct { - Message string `json:"message"` - Data T `json:"data"` -} - -type paginatedResource[T any] struct { - Message string `json:"message"` - Data []T `json:"data"` - Meta paginationMeta `json:"meta,omitempty"` -} - -type paginationMeta struct { - CurrentPage int `json:"current_page"` - LastPage int `json:"last_page"` - PerPage int `json:"per_page"` - Total int64 `json:"total"` -} - -func newPaginationInfo(currentPage, perPage int, total int64) paginationMeta { - lastPage := int(total) / perPage - if int(total)%perPage > 0 { - lastPage++ - } - return paginationMeta{ - CurrentPage: currentPage, - LastPage: lastPage, - PerPage: perPage, - Total: total, - } -} - -func writePaginatedResponse[T any](w http.ResponseWriter, status int, data []T, meta paginationMeta) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - resp := paginatedResource[T]{Message: "", Data: data, Meta: meta} - if err := json.NewEncoder(w).Encode(resp); err != nil { - return newResponseServiceError(err) - } - return nil -} - -func writeJsonResponse[T any](w http.ResponseWriter, status int, data T) error { - if any(data) == nil { - w.WriteHeader(status) - return nil - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if str, ok := any(data).(string); ok { - resp := resource[struct{}]{Message: str, Data: struct{}{}} - if err := json.NewEncoder(w).Encode(resp); err != nil { - return newResponseServiceError(err) - } - return nil - } - resp := resource[T]{Message: "Data fetched successfully", Data: data} - if err := json.NewEncoder(w).Encode(resp); err != nil { - return newResponseServiceError(err) - } - return nil -} - -func errorResponse(w http.ResponseWriter, status int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(resource[any]{Message: message}); err != nil { - logrus.Error("error writing error response: ", err) - http.Error(w, message, status) - } -} - -// handlerFunc is a route handler that returns its error for handle to write. -type handlerFunc func(w http.ResponseWriter, r *http.Request, log sLog) error - -// handle turns h into an http.Handler that writes h's error as core's -// handleError does. -func handle(h handlerFunc) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Start from core's fields, so an admin write's error line names the - // admin, as core's own handlers' lines do. - f := logrus.Fields(module.LogFields(r.Context())) - f["module"] = "programs" - f["method"] = r.Method - f["path"] = r.URL.Path - log := sLog{ctx: r.Context(), f: f} - if err := h(w, r, log); err != nil { - var svcErr serviceError - if errors.As(err, &svcErr) { - errorResponse(w, svcErr.Status, svcErr.Message) - log.error("Error occurred is ", svcErr.Err) - } else { - errorResponse(w, http.StatusInternalServerError, err.Error()) - log.error("Error occurred is ", err.Error()) - } - } - }) -} - -// sLog adds fields to a request's log lines, like core's sLog. -type sLog struct { - ctx context.Context - f logrus.Fields -} - -// add adds a field to this module's log lines and to core's, so it also -// reaches the admin audit line, as it did before the move. -func (slog sLog) add(key string, value any) { - slog.f[key] = value - module.LogField(slog.ctx, key, value) -} - -func (slog sLog) info(args ...any) { - logrus.WithFields(slog.f).Info(args...) -} - -func (slog sLog) warn(args ...any) { - logrus.WithFields(slog.f).Warn(args...) -} - -func (slog sLog) warnf(format string, args ...any) { - logrus.WithFields(slog.f).Warnf(format, args...) -} - -func (slog sLog) error(args ...any) { - logrus.WithFields(slog.f).Error(args...) -} - -func (slog sLog) errorf(format string, args ...any) { - logrus.WithFields(slog.f).Errorf(format, args...) -} - -// serviceError is an error with the status and message a handler answers -// with. -type serviceError struct { - Status int - Message string - Err error -} - -func (svcErr serviceError) Error() string { - return fmt.Sprintf("service error: %s", svcErr.Err.Error()) -} - -func newServiceError(err error, status int, msg string) serviceError { - svcErr := serviceError{Status: status, Message: msg, Err: err} - if dbErr, ok := err.(dbError); ok { - svcErr.Err = errors.Join(errors.New(dbErr.Message), dbErr.InternalErr) - } - return svcErr -} - -func newBadRequestServiceError(err error, msg string) serviceError { - return newServiceError(err, http.StatusBadRequest, msg) -} - -func newResponseServiceError(err error) serviceError { - return newServiceError(err, http.StatusInternalServerError, "error writing response") -} - -func newJSONReqBodyServiceError(err error) serviceError { - return newServiceError(err, http.StatusBadRequest, "error reading json request") -} - -func newInvalidIdServiceError(err error, idName string) serviceError { - return newServiceError(err, http.StatusBadRequest, fmt.Sprintf("invalid %s", idName)) -} - -func newInvalidQueryParamServiceError(err error, paramName string) serviceError { - return newServiceError(err, http.StatusBadRequest, fmt.Sprintf("invalid %s parameter", paramName)) -} - -func newUnauthorizedServiceError() serviceError { - return newServiceError(errors.New("user unauthorized"), http.StatusUnauthorized, "Unauthorized") -} - -func newForbiddenServiceError(err error, msg string) serviceError { - return newServiceError(err, http.StatusForbidden, msg) -} - -func newInternalServerServiceError(err error, msg string) serviceError { - return newServiceError(err, http.StatusInternalServerError, msg) -} - -// newDatabaseServiceError answers with a dbError's status and message, and -// with a 500 and the error's text for any other error. -func newDatabaseServiceError(err error) serviceError { - if dbErr, ok := err.(dbError); ok { - return serviceError{Status: dbErr.Status, Message: dbErr.Message, Err: dbErr.InternalErr} - } - return serviceError{Status: http.StatusInternalServerError, Message: err.Error(), Err: err} -} - -// dbError is a failed query, with the status and message to answer with. -type dbError struct { - Status int - Message string - InternalErr error -} - -func (e dbError) Error() string { - return fmt.Sprintf("db error: %s", e.InternalErr.Error()) -} - -func newDBError(err error, msg string) dbError { - dbErr := dbError{Message: msg, InternalErr: err} - if valErrs, ok := err.(validator.ValidationErrors); ok { - dbErr.Status = http.StatusBadRequest - dbErr.Message = fmt.Sprintf("%s failed: %v", msg, valErrs) - return dbErr - } - switch { - case errors.Is(err, gorm.ErrRecordNotFound), - errors.Is(err, gorm.ErrInvalidData), - errors.Is(err, gorm.ErrEmptySlice), - errors.Is(err, gorm.ErrInvalidField), - errors.Is(err, gorm.ErrInvalidValueOfLength): - dbErr.Status = http.StatusBadRequest - case errors.Is(err, gorm.ErrPrimaryKeyRequired), - errors.Is(err, gorm.ErrForeignKeyViolated), - errors.Is(err, gorm.ErrDuplicatedKey), - errors.Is(err, gorm.ErrCheckConstraintViolated): - dbErr.Status = http.StatusUnprocessableEntity - default: - dbErr.Status = http.StatusInternalServerError - } - return dbErr -} - -// dbCause is the error inside a dbError, which doesn't unwrap. The api -// returns it, so core wraps it in its own database error as it did before the -// move. -func dbCause(err error) error { - var dbErr dbError - if errors.As(err, &dbErr) { - return dbErr.InternalErr - } - return err -} - -func newGetRecordsDBError(err error, table string) dbError { - return newDBError(err, fmt.Sprintf("error getting %s", table)) -} - -func newDeleteDBError(err error, table string) dbError { - return newDBError(err, fmt.Sprintf("error deleting %s", table)) -} - -func newCreateDBError(err error, table string) dbError { - return newDBError(err, fmt.Sprintf("error creating %s", table)) -} - -func newUpdateDBError(err error, table string) dbError { - return newDBError(err, fmt.Sprintf("error updating %s", table)) -} - -func newNotFoundDBError(err error, table string) dbError { - return newDBError(err, fmt.Sprintf("%s not found", table)) -} - -// claimsFrom is the caller of a request. Core only hands a module requests -// it has authenticated, so the claims are always there. -func claimsFrom(r *http.Request) *identity.Claims { - c, _ := identity.FromContext(r.Context()) - return c -} - -// QueryContext is a list request's paging, filters and caller. -type QueryContext struct { - Params url.Values - Page int - PerPage int - FacilityID uint - UserID uint - OrderBy string - Order string - IsAdmin bool - CanSwitchFacility bool - Search string - Tags []string - Total int64 - All bool - Ctx context.Context - Timezone string - IncludeDeactivated bool - // FeatureAccess is the caller's effective (per-facility) feature set. - FeatureAccess []features.Access -} - -func (q *QueryContext) HasFeature(feature features.Access) bool { - return slices.Contains(q.FeatureAccess, feature) -} - -func (q *QueryContext) MaybeID(key string) *int { - var idptr *int - id, err := strconv.Atoi(q.Params.Get(key)) - if err == nil { - idptr = &id - } - return idptr -} - -func (q QueryContext) IntoMeta() paginationMeta { - return newPaginationInfo(q.Page, q.PerPage, q.Total) -} - -func (q QueryContext) CalcOffset() int { - return int(math.Abs(float64((q.Page - 1) * q.PerPage))) -} - -// OrderClause is the request's order, or fallback when it has none. -func (q QueryContext) OrderClause(fallback string) string { - val := q.OrderBy + " " + q.Order - if val == " " || q.OrderBy == "" { - return fallback - } - return val -} - -func (q QueryContext) SearchQuery() string { - if q.Search == "" { - return "" - } - return "%" + q.Search + "%" -} - -// getQueryContext resolves the facility scope for a request: a switch-capable -// admin (sysadmin/dept admin) gets the ?facility_id they're viewing, or 0 -// (statewide) when none is selected; a facility admin is always pinned to their -// own facility. -func getQueryContext(r *http.Request) QueryContext { - var userID uint - claims := claimsFrom(r) - facilityID := requestFacilityID(r, claims) - u, err := strconv.Atoi(r.URL.Query().Get("user_id")) - if err != nil { - userID = claims.UserID - } else { - userID = uint(u) - } - page, perPage := getPaginationInfo(r) - orderBy := strings.ToLower(r.URL.Query().Get("order_by")) - order := strings.ToLower(r.URL.Query().Get("order")) - if orderBy != "" && order == "" { - orderBySplit := strings.Fields(orderBy) - if len(orderBySplit) > 1 { - orderBy = orderBySplit[0] - order = orderBySplit[1] - } - } - return QueryContext{ - Params: r.URL.Query(), - Ctx: r.Context(), - Page: page, - PerPage: perPage, - FacilityID: facilityID, - UserID: userID, - OrderBy: orderBy, - Order: order, - IsAdmin: claims.IsAdmin(), - CanSwitchFacility: claims.CanSwitchFacility(), - Search: strings.TrimSpace(strings.ToLower(r.URL.Query().Get("search"))), - Tags: r.URL.Query()["tags"], - All: r.URL.Query().Get("all") == "true", - Timezone: claims.TimeZone, - IncludeDeactivated: r.URL.Query().Get("include_deactivated") == "true", - FeatureAccess: claims.FeatureAccess, - } -} - -// requestFacilityID is the facility a request is about, as getQueryContext -// resolves it. -func requestFacilityID(r *http.Request, claims *identity.Claims) uint { - f, err := strconv.Atoi(r.URL.Query().Get("facility_id")) - switch { - case err == nil && claims.CanSwitchFacility(): - return uint(f) - case claims.CanSwitchFacility(): - return 0 - default: - return claims.FacilityID - } -} - -// facilityScopedQueryContext is for reads whose results are inherently -// per-facility. A statewide admin has no single facility (FacilityID == 0), so -// these fall back to the admin's home facility rather than matching no -// facility at all. -func facilityScopedQueryContext(r *http.Request) QueryContext { - args := getQueryContext(r) - if args.FacilityID == 0 { - args.FacilityID = claimsFrom(r).FacilityID - } - return args -} - -// requireFacilityID is the facility a request acts on. A statewide admin must -// pick one. -func requireFacilityID(r *http.Request) (uint, error) { - facilityID := getQueryContext(r).FacilityID - if facilityID == 0 { - return 0, newBadRequestServiceError(nil, "facility selection is required") - } - return facilityID, nil -} - -func getPaginationInfo(r *http.Request) (int, int) { - page := r.URL.Query().Get("page") - perPage := r.URL.Query().Get("per_page") - if page == "" { - page = "1" - } - if perPage == "" { - perPage = "20" - } - intPage, err := strconv.Atoi(page) - if err != nil || intPage < 1 { - intPage = 1 - } - intPerPage, err := strconv.Atoi(perPage) - if err != nil || intPerPage < 1 { - intPerPage = 20 - } - return intPage, intPerPage -} - -// getDateRange is the start_dt and end_dt of a request, in the caller's time -// zone, two weeks either side of now when they are missing. -func getDateRange(r *http.Request) (*DateRange, error) { - tz, err := time.LoadLocation(claimsFrom(r).TimeZone) - if err != nil { - tz = time.UTC - } - - parseDate := func(dateStr string) (time.Time, error) { - formats := []string{"02-01-2006", "2006-01-02", "2006-01-02T15:04:05Z07:00"} - for _, format := range formats { - if t, err := time.Parse(format, dateStr); err == nil { - return t, nil - } - } - return time.Time{}, errors.New("invalid date format") - } - - start, err := parseDate(r.URL.Query().Get("start_dt")) - if err != nil { - // if start is not provided, use two weeks ago - start = time.Now().Add(-14 * 24 * time.Hour) - } - start = start.In(tz) - end, err := parseDate(r.URL.Query().Get("end_dt")) - if err != nil { - // if end is not provided, use two weeks from now - end = time.Now().Add(14 * 24 * time.Hour) - } - end = end.In(tz) - if start.After(end) { - return nil, errors.New("start date cannot be after end date") - } - return &DateRange{ - Start: start, - End: end, - Tzone: tz, - }, nil -} - -func writeConflictResponse(w http.ResponseWriter, conflicts []RoomConflict) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - resp := resource[[]RoomConflict]{ - Message: conflictResponseMessage(conflicts), - Data: conflicts, - } - if err := json.NewEncoder(w).Encode(resp); err != nil { - return newResponseServiceError(err) - } - return nil -} - -func conflictResponseMessage(conflicts []RoomConflict) string { - var hasRoom, hasInstructor bool - for _, c := range conflicts { - switch c.ConflictType { - case ConflictTypeInstructor: - hasInstructor = true - default: - hasRoom = true - } - } - switch { - case hasRoom && hasInstructor: - return "the room and instructor are already booked during this time" - case hasInstructor: - return "the instructor is already teaching during this time" - default: - return "room is already booked during this time" - } -} - -func writeDeleteConflictResponse(w http.ResponseWriter, message string, blockers DeleteBlockingChildren) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - resp := resource[DeleteBlockingChildren]{ - Message: message, - Data: blockers, - } - if err := json.NewEncoder(w).Encode(resp); err != nil { - return newResponseServiceError(err) - } - return nil -} - -func calcOffset(page, perPage int) int { - return (page - 1) * perPage -} - -// updateStruct copies src's non-zero fields, and every bool, onto dst. -func updateStruct(dst, src any) { - srcVal := reflect.ValueOf(src).Elem() - dstVal := reflect.ValueOf(dst).Elem() - - if srcVal.Kind() == reflect.Pointer { - srcVal = srcVal.Elem() - } - - if dstVal.Kind() == reflect.Pointer { - dstVal = dstVal.Elem() - } - - for i := 0; i < srcVal.NumField(); i++ { - srcField := srcVal.Field(i) - dstField := dstVal.Field(i) - - if srcField.Kind() == reflect.Bool { //always set booleans - dstField.Set(srcField) - continue - } - - if !reflect.DeepEqual(srcField.Interface(), reflect.Zero(srcField.Type()).Interface()) { - dstField.Set(srcField) - } - } -} diff --git a/backend/src/modules/programs/instructor_conflicts_store.go b/backend/src/modules/programs/instructor_conflicts_store.go deleted file mode 100644 index 4f12f4944..000000000 --- a/backend/src/modules/programs/instructor_conflicts_store.go +++ /dev/null @@ -1,128 +0,0 @@ -package programs - -import ( - "fmt" - "sort" - "time" - - "github.com/sirupsen/logrus" - "github.com/teambition/rrule-go" -) - -func checkInstructorRRuleConflicts(db *Store, w *conflictWindow, req *ConflictCheckRequest) ([]RoomConflict, error) { - bookings, err := db.getInstructorBookingsInRange(req.FacilityID, req.InstructorID, w.rangeStart, w.rangeEnd, w.facilityTZ) - if err != nil { - return nil, err - } - return buildConflictsFromBookings(db, w, bookings, req, ConflictTypeInstructor), nil -} - -func (db *Store) getInstructorBookingsInRange(facilityID, instructorID uint, rangeStart, rangeEnd time.Time, facilityTimezone string) ([]RoomBooking, error) { - var events []ProgramClassEvent - if err := db.Table("program_class_events e"). - Select("DISTINCT e.*"). - Joins("JOIN program_class_cohorts c ON c.id = e.cohort_id"). - Joins("LEFT JOIN program_class_event_overrides o ON o.event_id = e.id AND o.deleted_at IS NULL"). - Where("c.facility_id = ? AND e.deleted_at IS NULL AND (e.instructor_id = ? OR o.instructor_id = ?)", facilityID, instructorID, instructorID). - Preload("Overrides"). - Find(&events).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - - var bookings []RoomBooking - for _, event := range events { - eventBookings, err := expandEventToInstructorBookings(event, rangeStart, rangeEnd, instructorID, facilityTimezone) - if err != nil { - return nil, err - } - bookings = append(bookings, eventBookings...) - } - return bookings, nil -} - -func expandEventToInstructorBookings(event ProgramClassEvent, rangeStart, rangeEnd time.Time, targetInstructorID uint, facilityTimezone string) ([]RoomBooking, error) { - var bookings []RoomBooking - - rule, err := event.GetRRuleWithTimezone(facilityTimezone) - if err != nil { - return nil, fmt.Errorf("invalid recurrence rule for event %d: %w", event.ID, err) - } - - duration, err := time.ParseDuration(event.Duration) - if err != nil { - return nil, fmt.Errorf("invalid duration '%s' for event %d: %w", event.Duration, event.ID, err) - } - - cancelledDates := make(map[string]bool) - rescheduledDates := make(map[string]ProgramClassEventOverride) - for _, override := range event.Overrides { - overrideRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("skipping override %d with invalid rrule: %v", override.ID, err) - continue - } - for _, occ := range overrideRule.Between(rangeStart, rangeEnd, true) { - dateKey := occ.Format("2006-01-02") - if override.IsCancelled { - cancelledDates[dateKey] = true - } else { - rescheduledDates[dateKey] = override - } - } - } - - if event.InstructorID != nil && *event.InstructorID == targetInstructorID { - for _, occ := range rule.Between(rangeStart, rangeEnd, true) { - dateKey := occ.Format("2006-01-02") - if cancelledDates[dateKey] { - continue - } - if _, rescheduled := rescheduledDates[dateKey]; rescheduled { - continue - } - bookings = append(bookings, RoomBooking{ - StartTime: occ, - EndTime: occ.Add(duration), - EventID: event.ID, - ClassID: event.CohortID, - }) - } - } - - for _, override := range event.Overrides { - if override.IsCancelled { - continue - } - effectiveInstructor := event.InstructorID - if override.InstructorID != nil { - effectiveInstructor = override.InstructorID - } - if effectiveInstructor == nil || *effectiveInstructor != targetInstructorID { - continue - } - overrideRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("skipping override %d with invalid rrule: %v", override.ID, err) - continue - } - overrideDuration, err := time.ParseDuration(override.Duration) - if err != nil { - overrideDuration = duration - } - for _, occ := range overrideRule.Between(rangeStart, rangeEnd, true) { - bookings = append(bookings, RoomBooking{ - StartTime: occ, - EndTime: occ.Add(overrideDuration), - EventID: event.ID, - ClassID: event.CohortID, - IsOverride: true, - }) - } - } - - sort.Slice(bookings, func(i, j int) bool { - return bookings[i].StartTime.Before(bookings[j].StartTime) - }) - - return bookings, nil -} diff --git a/backend/src/modules/programs/jobs.go b/backend/src/modules/programs/jobs.go deleted file mode 100644 index 194a41bd6..000000000 --- a/backend/src/modules/programs/jobs.go +++ /dev/null @@ -1,65 +0,0 @@ -package programs - -import ( - "context" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/identity" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - - log "github.com/sirupsen/logrus" -) - -// Programs' background job: class activation. It runs in the scheduler -// itself. Until EN-197, core's scheduler published it to NATS and -// provider-middleware ran it. - -const ( - // everyMorningAt5AM is when class activation runs, in the scheduler's time - // zone. - everyMorningAt5AM = "0 5 * * *" - activationTimeout = 30 * time.Minute -) - -// scheduleJobs runs class activation every morning and, outside dev, once -// at start. -func scheduleJobs(s module.Scheduler, store *Store, dev bool) { - run := func() { - ctx, cancel := context.WithTimeout(context.Background(), activationTimeout) - defer cancel() - if err := activateScheduledClasses(ctx, store); err != nil { - log.Errorf("failed to activate scheduled classes: %v", err) - } - } - if err := s.Cron(everyMorningAt5AM, run); err != nil { - log.Errorf("Failed to schedule class activation: %v", err) - } - if dev { - return - } - if err := s.AtStart(run); err != nil { - log.Errorf("Failed to schedule class activation at start: %v", err) - } -} - -// activateScheduledClasses makes Active every Scheduled class whose start -// date has come in its facility's time zone, and gives its Enrolled -// enrollments that have no enrolled date today's date. It makes the changes -// as the system_batch user, which the models' update hooks record. -func activateScheduledClasses(ctx context.Context, store *Store) error { - batchUserID, err := store.SystemBatchUserID(ctx) - if err != nil { - return err - } - cohortIDs, err := store.ScheduledCohortsDue(ctx) - if err != nil { - return err - } - if len(cohortIDs) == 0 { - log.Infoln("no scheduled classes are due for activation") - return nil - } - log.Infof("activating %d scheduled class(es): %v", len(cohortIDs), cohortIDs) - batchCtx := identity.NewContext(ctx, &identity.Claims{UserID: batchUserID}) - return store.ActivateCohorts(batchCtx, cohortIDs, batchUserID, time.Now().UTC()) -} diff --git a/backend/src/modules/programs/jobs_test.go b/backend/src/modules/programs/jobs_test.go deleted file mode 100644 index 14bbce97d..000000000 --- a/backend/src/modules/programs/jobs_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package programs - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/identity" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/gorm" -) - -// fakeScheduler records what a module schedules. -type fakeScheduler struct { - specs []string - atStart int -} - -func (s *fakeScheduler) Cron(spec string, task func()) error { - s.specs = append(s.specs, spec) - return nil -} - -func (s *fakeScheduler) AtStart(task func()) error { - s.atStart++ - return nil -} - -// Class activation runs every morning at 5 and, outside dev, once at start. -func TestScheduleJobs(t *testing.T) { - for _, dev := range []bool{false, true} { - s := &fakeScheduler{} - scheduleJobs(s, &Store{newTestDB(t)}, dev) - assert.Equal(t, []string{"0 5 * * *"}, s.specs) - wantAtStart := 1 - if dev { - wantAtStart = 0 - } - assert.Equal(t, wantAtStart, s.atStart, "dev=%v", dev) - } -} - -func createCohort(t *testing.T, db *gorm.DB, name string, status ClassStatus) ProgramClassCohort { - t.Helper() - program := Program{Name: name} - require.NoError(t, db.Create(&program).Error) - cohort := ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: 1, - Capacity: 10, - Description: name, - StartDt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), - Status: status, - } - require.NoError(t, db.Create(&cohort).Error) - return cohort -} - -func createEnrollment(t *testing.T, db *gorm.DB, cohort ProgramClassCohort, userID uint, status ProgramEnrollmentStatus, enrolledAt *time.Time) ProgramClassEnrollment { - t.Helper() - e := ProgramClassEnrollment{CohortID: cohort.ID, UserID: userID, EnrollmentStatus: status, EnrolledAt: enrolledAt} - require.NoError(t, db.Create(&e).Error) - return e -} - -// Activating classes makes them Active and gives their Enrolled enrollments -// with no enrolled date the activation time, all as the batch user. Other -// classes and enrollments are left alone. -func TestActivateCohorts(t *testing.T) { - db := newTestDB(t) - store := &Store{db} - due := createCohort(t, db, "due", Scheduled) - other := createCohort(t, db, "other", Scheduled) - earlier := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) - undated := createEnrollment(t, db, due, 1, Enrolled, nil) - dated := createEnrollment(t, db, due, 2, Enrolled, &earlier) - dropped := createEnrollment(t, db, due, 3, EnrollmentIncompleteDropped, nil) - elsewhere := createEnrollment(t, db, other, 4, Enrolled, nil) - const batchUserID = 99 - at := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) - - ctx := identity.NewContext(context.Background(), &identity.Claims{UserID: batchUserID}) - require.NoError(t, store.ActivateCohorts(ctx, []int{int(due.ID)}, batchUserID, at)) - - var got ProgramClassCohort - require.NoError(t, db.First(&got, due.ID).Error) - assert.Equal(t, Active, got.Status) - require.NotNil(t, got.UpdateUserID) - assert.Equal(t, uint(batchUserID), *got.UpdateUserID) - var untouched ProgramClassCohort - require.NoError(t, db.First(&untouched, other.ID).Error) - assert.Equal(t, Scheduled, untouched.Status) - - enrolledAt := func(e ProgramClassEnrollment) *time.Time { - var row ProgramClassEnrollment - require.NoError(t, db.First(&row, e.ID).Error) - return row.EnrolledAt - } - require.NotNil(t, enrolledAt(undated)) - assert.True(t, enrolledAt(undated).Equal(at)) - assert.True(t, enrolledAt(dated).Equal(earlier)) - assert.Nil(t, enrolledAt(dropped)) - assert.Nil(t, enrolledAt(elsewhere)) - var row ProgramClassEnrollment - require.NoError(t, db.First(&row, undated.ID).Error) - require.NotNil(t, row.UpdateUserID) - assert.Equal(t, uint(batchUserID), *row.UpdateUserID) -} - -// Without the system_batch user the job stops before changing anything. -func TestActivateScheduledClassesNoBatchUser(t *testing.T) { - db := newTestDB(t) - cohort := createCohort(t, db, "scheduled", Scheduled) - - err := activateScheduledClasses(context.Background(), &Store{db}) - - require.Error(t, err) - assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) - var got ProgramClassCohort - require.NoError(t, db.First(&got, cohort.ID).Error) - assert.Equal(t, Scheduled, got.Status) -} - -func TestSystemBatchUserID(t *testing.T) { - db := newTestDB(t) - require.NoError(t, db.Create(&User{Username: "someone", Email: "a@x"}).Error) - batch := User{Username: "system_batch", Email: "b@x"} - require.NoError(t, db.Create(&batch).Error) - - id, err := (&Store{db}).SystemBatchUserID(context.Background()) - require.NoError(t, err) - assert.Equal(t, batch.ID, id) -} diff --git a/backend/src/modules/programs/live_provider.go b/backend/src/modules/programs/live_provider.go deleted file mode 100644 index b1b561a14..000000000 --- a/backend/src/modules/programs/live_provider.go +++ /dev/null @@ -1,306 +0,0 @@ -package programs - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/coreapi" - "github.com/UnlockedLabs/unlocked-contracts/providerplatforms" -) - -// A live provider is one whose courses are read over HTTP on request and projected -// as synthetic programs and classes, rather than synced into our tables. Canvas is -// the first; the seams here exist so a second provider can differ in the two ways -// that actually matter -- how it authenticates, and how it pages -- without -// duplicating the handler layer. -// -// Synthetic IDs are deliberately not part of this: encodeCanvasClassID and friends -// key off provider_platforms.id, never the provider type, so every live provider -// already gets a disjoint ID range for free. - -// providerTransport is the per-provider HTTP seam. Canvas authenticates with a -// bearer token and pages via RFC-5988 Link headers; other providers set a -// different header and page by other means. -type providerTransport interface { - // authorize applies whatever credential scheme the provider uses. - authorize(req *http.Request, provider *providerplatforms.Provider) - - // fetchAll retrieves every page starting at startURL and returns the combined - // records. max limits the total returned; 0 means fetch everything. - fetchAll(ctx context.Context, provider *providerplatforms.Provider, startURL string, max int) ([]map[string]interface{}, error) - - // fetchOne retrieves a single JSON object, for endpoints that return a bare - // record rather than a collection. - fetchOne(ctx context.Context, provider *providerplatforms.Provider, url string) (map[string]interface{}, error) -} - -// requireHTTPS reports whether a URL is safe to send a provider's access key to. -// The handlers reject a non-https base URL on the way in; this is the backstop -// for rows that predate that check and for a paging link that points somewhere -// the provider chose. -func requireHTTPS(rawURL string) error { - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil { - return fmt.Errorf("parsing provider url: %w", err) - } - if parsed.Scheme != "https" { - return fmt.Errorf("provider url must use https, got %q", parsed.Scheme) - } - return nil -} - -// canvasTransport implements providerTransport for Canvas. -type canvasTransport struct { - srv *server -} - -func (t canvasTransport) authorize(req *http.Request, provider *providerplatforms.Provider) { - req.Header.Add("Authorization", "Bearer "+provider.AccessKey) - req.Header.Add("Accept", "application/json") -} - -func (t canvasTransport) fetchAll(ctx context.Context, provider *providerplatforms.Provider, startURL string, max int) ([]map[string]interface{}, error) { - return t.srv.fetchAllCanvasPages(ctx, provider, startURL, max) -} - -func (t canvasTransport) fetchOne(ctx context.Context, provider *providerplatforms.Provider, url string) (map[string]interface{}, error) { - if err := requireHTTPS(url); err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - t.authorize(req, provider) - resp, err := t.srv.Client.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("canvas returned %d", resp.StatusCode) - } - var record map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { - return nil, err - } - return record, nil -} - -// maxRawCourseID is the largest provider course ID the synthetic class ID -// encoding can carry. encodeCanvasClassID packs the course into the low six -// digits, so an ID at or above this overflows into the provider field and the -// class would silently be attributed to a different provider. Callers skip such -// courses rather than emitting a corrupt ID. -const maxRawCourseID = 999_999 - -// courseIDFitsEncoding reports whether a provider course ID can be encoded -// without colliding with another provider's range. -func courseIDFitsEncoding(rawCourseID uint) bool { - return rawCourseID <= maxRawCourseID -} - -// liveCourseList is what a provider reports for one read of its course listing. -type liveCourseList struct { - // Courses are the records that parsed successfully. - Courses []liveCourse - // Timezones maps a raw course ID to its IANA timezone, for the courses where - // the provider reports one. - Timezones map[uint]string - // Total is how many course records the provider returned, including any that - // could not be parsed. Class counts are reported against this, not against - // len(Courses), so a malformed record still shows up in the total. - Total int -} - -// LiveProgramProvider reads a provider's courses on request. Implementations are -// obtained through newLiveProgramProvider, which fails for provider types that -// are not read this way. -type LiveProgramProvider interface { - // ListCourses returns every course this provider platform exposes. - ListCourses(ctx context.Context) (liveCourseList, error) - - // ListCourseEnrollees returns the provider's own user IDs for the students in - // a course. Only called when a liveCourse did not already carry them. - ListCourseEnrollees(ctx context.Context, rawCourseID uint) ([]string, error) - - // GetCourse returns one course by the provider's own course ID, along with - // its IANA timezone where the provider reports one. - GetCourse(ctx context.Context, rawCourseID uint) (liveCourse, string, error) -} - -// courseEnrollees returns the enrollee IDs for a course, using the ones the -// listing already supplied when the provider reports them inline and falling -// back to a per-course request when it does not. -func courseEnrollees(ctx context.Context, reader LiveProgramProvider, course liveCourse) ([]string, error) { - if course.enrolleeIDs != nil { - return course.enrolleeIDs, nil - } - return reader.ListCourseEnrollees(ctx, course.rawID) -} - -// canvasProvider is the Canvas implementation of LiveProgramProvider. -type canvasProvider struct { - srv *server - provider *providerplatforms.Provider - transport providerTransport -} - -func (p canvasProvider) ListCourses(ctx context.Context) (liveCourseList, error) { - raw, err := p.transport.fetchAll(ctx, p.provider, canvasAccountCoursesURL(p.provider), 0) - if err != nil { - return liveCourseList{}, fmt.Errorf("canvas API error for provider %d: %w", p.provider.ID, err) - } - now := time.Now() - courses := make([]liveCourse, 0, len(raw)) - for _, record := range raw { - course, ok := parseCanvasCourse(record, p.provider.ID, now) - if !ok { - continue - } - courses = append(courses, course) - } - return liveCourseList{ - Courses: courses, - Timezones: buildCourseTimezoneMap(raw), - Total: len(raw), - }, nil -} - -// GetCourse reads the single-course endpoint. Canvas reports a per-course -// timezone, which the class detail page uses to render schedule times. -func (p canvasProvider) GetCourse(ctx context.Context, rawCourseID uint) (liveCourse, string, error) { - record, err := p.transport.fetchOne(ctx, p.provider, canvasCourseURL(p.provider, rawCourseID)) - if err != nil { - return liveCourse{}, "", fmt.Errorf("fetching canvas course %d: %w", rawCourseID, err) - } - course, ok := parseCanvasCourse(record, p.provider.ID, time.Now()) - if !ok { - return liveCourse{}, "", fmt.Errorf("canvas course %d could not be parsed", rawCourseID) - } - timezone, _ := record["time_zone"].(string) - return course, timezone, nil -} - -func (p canvasProvider) ListCourseEnrollees(ctx context.Context, rawCourseID uint) ([]string, error) { - return p.srv.fetchCanvasCourseEnrolleeIDs(p.provider, rawCourseID) -} - -// newLiveProgramProvider returns the live reader for a provider platform, or an -// error when the type is not read live. -func (srv *server) newLiveProgramProvider(provider *providerplatforms.Provider) (LiveProgramProvider, error) { - transport, err := srv.transportFor(provider) - if err != nil { - return nil, err - } - if provider.Type == providerplatforms.EssentialEd { - return essentialEdProvider{srv: srv, provider: provider, transport: transport}, nil - } - return canvasProvider{srv: srv, provider: provider, transport: transport}, nil -} - -// canvasAccountCoursesURL is the account course listing every Canvas read path -// starts from. It was copied inline in seven places before this existed. -func canvasAccountCoursesURL(provider *providerplatforms.Provider) string { - return provider.BaseURL + "/api/v1/accounts/" + provider.AccountID + "/courses?per_page=100" -} - -// canvasCourseURL is the single-course endpoint. -func canvasCourseURL(provider *providerplatforms.Provider, rawCourseID uint) string { - return fmt.Sprintf("%s/api/v1/courses/%d", provider.BaseURL, rawCourseID) -} - -// liveUserLister is implemented by live providers that can list their own user -// accounts directly. Providers that do not implement it (Canvas) continue to be -// served by Provider Platforms' worker over HTTP. -type liveUserLister interface { - ListUsers(ctx context.Context) ([]coreapi.ProviderUser, error) -} - -// liveUserListerFor returns the provider's own user lister when it has one. -func (srv *server) liveUserListerFor(provider *providerplatforms.Provider) (liveUserLister, bool) { - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return nil, false - } - lister, ok := reader.(liveUserLister) - return lister, ok -} - -// liveUserCourseLister is implemented by live providers that can report the -// courses one of their users is enrolled in. Canvas is not: it has a dedicated -// per-user endpoint, served by fetchCanvasCoursesForUser. -type liveUserCourseLister interface { - ListUserCourses(ctx context.Context, externalUserID string) ([]liveCourse, error) -} - -// providerByID is the provider platform with id, from Provider Platforms. A -// missing one is the zero Provider, which isn't live. -func (srv *server) providerByID(ctx context.Context, id uint) (*providerplatforms.Provider, error) { - provider, err := srv.providerPlatforms.Provider(ctx, id) - if err != nil { - return nil, newDBError(err, "provider_platforms not found") - } - return &provider, nil -} - -// activeProviders is every enabled provider platform, from Provider Platforms. -func (srv *server) activeProviders(ctx context.Context) ([]providerplatforms.Provider, error) { - providers, err := srv.providerPlatforms.ActiveProviders(ctx) - if err != nil { - return nil, newDBError(err, "error getting provider_platforms") - } - return providers, nil -} - -// providerCourseEnrolleeIDs returns the provider's own student IDs for a course, -// dispatching on provider type. This is what the enrollee counters use, so they -// work for any live provider rather than only Canvas. -func (srv *server) providerCourseEnrolleeIDs(provider *providerplatforms.Provider, rawCourseID uint) ([]string, error) { - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return nil, err - } - return reader.ListCourseEnrollees(context.Background(), rawCourseID) -} - -// providerSourceLabel is the value the frontend uses to tell where a synthetic -// program came from. It is sent as ProgramsOverviewTable.Source. -func providerSourceLabel(p *providerplatforms.Provider) string { - if p.Type == providerplatforms.EssentialEd { - return "essential_ed" - } - return "canvas" -} - -// providerDisplayName is the human-readable provider name used in descriptions. -func providerDisplayName(p *providerplatforms.Provider) string { - if p.Type == providerplatforms.EssentialEd { - return "Essential Education" - } - return "Canvas" -} - -// isLiveProgramProvider reports whether this provider platform is read live and -// projected as synthetic programs. Prefer this over isCanvasProvider at call -// sites that only mean "is this one of the live providers". -func isLiveProgramProvider(p *providerplatforms.Provider) bool { - return isCanvasProvider(p) || p.Type == providerplatforms.EssentialEd -} - -// transportFor returns the HTTP seam for a provider platform, or an error when -// the type is not a live provider. -func (srv *server) transportFor(provider *providerplatforms.Provider) (providerTransport, error) { - switch { - case isCanvasProvider(provider): - return canvasTransport{srv: srv}, nil - case provider.Type == providerplatforms.EssentialEd: - return essentialEdTransport{srv: srv}, nil - } - return nil, fmt.Errorf("provider %d (%s) is not a live program provider", provider.ID, provider.Type) -} diff --git a/backend/src/modules/programs/migrations/00001_move_into_programs_schema.sql b/backend/src/modules/programs/migrations/00001_move_into_programs_schema.sql deleted file mode 100644 index f5734a3b2..000000000 --- a/backend/src/modules/programs/migrations/00001_move_into_programs_schema.sql +++ /dev/null @@ -1,135 +0,0 @@ --- +goose Up --- Programs' tables move into their own schema, and its queries run as the --- programs role, which can write only that schema, read core's users and --- facilities, and add to core's audit tables (EN-200). Goose runs this in one --- transaction, so a failure leaves the database as it was. --- --- SET SCHEMA keeps each table's rows, indexes, constraints, triggers, owned --- sequences and column defaults, and foreign keys keep working across schemas. --- The enum types (class_status, credit_type, funding_type, program_type) stay --- in public, as Open Content's did. --- --- Each move holds a brief exclusive lock on its table. If a long query holds --- one first, this gives up instead of queueing every later query behind it; --- the transaction rolls back and the migration can run again. -SET LOCAL lock_timeout = '5s'; - -CREATE SCHEMA programs; - -ALTER TABLE public.programs SET SCHEMA programs; -ALTER TABLE public.program_types SET SCHEMA programs; -ALTER TABLE public.program_credit_types SET SCHEMA programs; -ALTER TABLE public.facilities_programs SET SCHEMA programs; -ALTER TABLE public.program_classes SET SCHEMA programs; -ALTER TABLE public.program_class_credit_types SET SCHEMA programs; -ALTER TABLE public.program_class_cohorts SET SCHEMA programs; -ALTER TABLE public.program_class_events SET SCHEMA programs; -ALTER TABLE public.program_class_event_overrides SET SCHEMA programs; -ALTER TABLE public.program_class_event_attendance SET SCHEMA programs; -ALTER TABLE public.program_class_enrollments SET SCHEMA programs; -ALTER TABLE public.class_completions SET SCHEMA programs; -ALTER TABLE public.program_classes_history SET SCHEMA programs; -ALTER TABLE public.rooms SET SCHEMA programs; - --- The history trigger on programs, program_classes and program_class_cohorts --- names its table with the schema, so it moves too and writes the moved table. --- The triggers point at the function itself, so they follow it. -ALTER FUNCTION public.log_program_classes_updates() SET SCHEMA programs; --- +goose StatementBegin -CREATE OR REPLACE FUNCTION programs.log_program_classes_updates() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - INSERT INTO programs.program_classes_history (parent_ref_id, table_name, before_update, after_update) - VALUES ( - OLD.id, - TG_TABLE_NAME, - row_to_json(OLD), - row_to_json(NEW) - ); - RETURN NEW; -END; -$$; --- +goose StatementEnd - --- The role can't log in: the app connects as its own user and switches to --- programs with SET ROLE, so no new password is needed. Roles belong to the --- whole Postgres server, so it may already exist. This assumes one Unlocked --- database per server: a second one with a different app user fails at the --- GRANT below, and its Down can't drop a role another database uses. --- +goose StatementBegin -DO $$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'programs') THEN - CREATE ROLE programs NOLOGIN; - END IF; -END -$$; --- +goose StatementEnd -GRANT programs TO CURRENT_USER; - -GRANT USAGE ON SCHEMA programs TO programs; -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA programs TO programs; -GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA programs TO programs; --- Tables and sequences later migrations add here get the same grants. -ALTER DEFAULT PRIVILEGES IN SCHEMA programs GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO programs; -ALTER DEFAULT PRIVILEGES IN SCHEMA programs GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO programs; - -GRANT USAGE ON SCHEMA public TO programs; -GRANT SELECT ON public.users, public.facilities TO programs; --- Programs writes core's audit trail in its own transactions (attendance, --- enrollment and class changes), so it may read and add rows. Deleting a --- program or class also deletes its change log, so it may remove those rows --- too. It never changes the trail, or removes account history. -GRANT SELECT, INSERT, DELETE ON public.change_log_entries TO programs; -GRANT SELECT, INSERT ON public.user_account_history TO programs; -GRANT USAGE ON SEQUENCE public.change_log_entries_id_seq, public.user_account_history_id_seq TO programs; - --- +goose Down --- Moving the tables back takes the same locks, so it gives up the same way. -SET LOCAL lock_timeout = '5s'; - -ALTER DEFAULT PRIVILEGES IN SCHEMA programs REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM programs; -ALTER DEFAULT PRIVILEGES IN SCHEMA programs REVOKE USAGE, SELECT, UPDATE ON SEQUENCES FROM programs; - --- +goose StatementBegin -CREATE OR REPLACE FUNCTION programs.log_program_classes_updates() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - INSERT INTO public.program_classes_history (parent_ref_id, table_name, before_update, after_update) - VALUES ( - OLD.id, - TG_TABLE_NAME, - row_to_json(OLD), - row_to_json(NEW) - ); - RETURN NEW; -END; -$$; --- +goose StatementEnd -ALTER FUNCTION programs.log_program_classes_updates() SET SCHEMA public; - -ALTER TABLE programs.programs SET SCHEMA public; -ALTER TABLE programs.program_types SET SCHEMA public; -ALTER TABLE programs.program_credit_types SET SCHEMA public; -ALTER TABLE programs.facilities_programs SET SCHEMA public; -ALTER TABLE programs.program_classes SET SCHEMA public; -ALTER TABLE programs.program_class_credit_types SET SCHEMA public; -ALTER TABLE programs.program_class_cohorts SET SCHEMA public; -ALTER TABLE programs.program_class_events SET SCHEMA public; -ALTER TABLE programs.program_class_event_overrides SET SCHEMA public; -ALTER TABLE programs.program_class_event_attendance SET SCHEMA public; -ALTER TABLE programs.program_class_enrollments SET SCHEMA public; -ALTER TABLE programs.class_completions SET SCHEMA public; -ALTER TABLE programs.program_classes_history SET SCHEMA public; -ALTER TABLE programs.rooms SET SCHEMA public; - -DROP SCHEMA programs; - --- DROP OWNED revokes every grant the role holds in this database. -DROP OWNED BY programs; -REVOKE programs FROM CURRENT_USER; -DROP ROLE programs; diff --git a/backend/src/modules/programs/migrations/README.md b/backend/src/modules/programs/migrations/README.md deleted file mode 100644 index 93718acee..000000000 --- a/backend/src/modules/programs/migrations/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Programs migrations - -Goose `.sql` migrations for this module, embedded in the server binary. They run -after core's, and their versions are tracked in `public.goose_db_version_programs`. - -Never edit a migration that has been merged; add a new one. - -Create new tables as `programs.`. Migrations run on core's connection, -so an unqualified table lands in `public`, where the `programs` role can't use -it. diff --git a/backend/src/modules/programs/models.go b/backend/src/modules/programs/models.go deleted file mode 100644 index f1dd3e288..000000000 --- a/backend/src/modules/programs/models.go +++ /dev/null @@ -1,298 +0,0 @@ -package programs - -import ( - "fmt" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api" - - "github.com/UnlockedLabs/unlocked-contracts/identity" - "github.com/UnlockedLabs/unlocked-contracts/users" - "gorm.io/gorm" -) - -// The rows of core's tables that Programs reads and writes, and the Programs -// types that lived in core's shared model files. Their JSON is what core sent -// before the move (EN-194), so responses don't change. - -// DatabaseFields are the columns most tables share. The create and update user -// come from the identity.Claims in the query's context. -type DatabaseFields struct { - ID uint `gorm:"primaryKey" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - CreateUserID *uint `json:"create_user_id"` - UpdateUserID *uint `json:"update_user_id"` -} - -func (m *DatabaseFields) BeforeCreate(tx *gorm.DB) error { - if c, ok := identity.FromContext(tx.Statement.Context); ok { - m.CreateUserID = &c.UserID - } - return nil -} - -func (m *DatabaseFields) BeforeUpdate(tx *gorm.DB) error { - if c, ok := identity.FromContext(tx.Statement.Context); ok { - m.UpdateUserID = &c.UserID - tx.Statement.SetColumn("update_user_id", c.UserID) - } - return nil -} - -// User is a row of core's users table. -type User struct { - DatabaseFields - Username string `gorm:"size:255;not null;unique" json:"username"` - NameFirst string `gorm:"size:255;not null" json:"name_first"` - Email string `gorm:"size:255;not null;unique" json:"email"` - NameLast string `gorm:"size:255;not null" json:"name_last"` - Role users.Role `gorm:"size:64;default:student" json:"role"` - KratosID *string `gorm:"size:255" json:"kratos_id"` - FacilityID uint `json:"facility_id"` - DocID *string `json:"doc_id" gorm:"column:doc_id;size:32"` - DeactivatedAt *time.Time `json:"deactivated_at,omitempty"` - - Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID"` -} - -func (User) TableName() string { return "users" } - -// Facility is a row of core's facilities table. -type Facility struct { - DatabaseFields - Name string `gorm:"size:255;not null" json:"name"` - Timezone string `gorm:"size:255;not null" json:"timezone"` - - // Programs is always null, as it was in core. - Programs []Program `json:"programs" gorm:"-"` - // LoginActivity is core's, which Programs never loads, so it is always - // null. - LoginActivity []struct{} `json:"login_activity" gorm:"-"` - Rooms []Room `json:"rooms,omitempty" gorm:"foreignKey:FacilityID;references:ID"` -} - -func (Facility) TableName() string { return "facilities" } - -// UserAccountHistory is a row of core's user_account_history table. Programs -// adds one when it records attendance. -type UserAccountHistory struct { - UserID uint `json:"user_id" gorm:"primaryKey"` - AdminID *uint `json:"admin_id"` - Action ActivityHistoryAction `json:"action" gorm:"size:255;primaryKey"` - ProgramClassesHistoryID *uint `json:"program_classes_history_id"` - FacilityID *uint `json:"facility_id"` - CreatedAt time.Time `json:"created_at" gorm:"primaryKey"` - AttendanceStatus Attendance `json:"attendance_status" gorm:"size:27"` - ClassName *string `json:"class_name" gorm:"size:255"` - SessionDate *time.Time `json:"session_date" gorm:"type:date"` -} - -func (UserAccountHistory) TableName() string { return "user_account_history" } - -// ActivityHistoryAction is a user_account_history action. -type ActivityHistoryAction string - -const ( - ProgClassHistory ActivityHistoryAction = "progclass_history" - AttendanceRecorded ActivityHistoryAction = "attendance_recorded" -) - -func NewUserAccountHistory(userID uint, action ActivityHistoryAction, adminID *uint, programClassesHistoryID *uint, facilityID *uint) *UserAccountHistory { - return &UserAccountHistory{ - UserID: userID, - AdminID: adminID, - Action: action, - ProgramClassesHistoryID: programClassesHistoryID, - FacilityID: facilityID, - CreatedAt: time.Now(), - } -} - -// ActivityHistoryResponse is one entry of a program's or class's history. -type ActivityHistoryResponse struct { - Action ActivityHistoryAction `json:"action"` - CreatedAt *time.Time `json:"created_at"` - FieldName *string `json:"field_name"` - NewValue *string `json:"new_value"` - OldValue *string `json:"old_value"` - UserID *uint `json:"user_id"` - UserUsername *string `json:"user_username"` - AdminUsername *string `json:"admin_username"` - FacilityName *string `json:"facility_name"` - ProgramClassesHistoryID *uint `json:"program_classes_history_id"` - AttendanceStatus *string `json:"attendance_status"` - ClassName *string `json:"class_name"` - SessionDate *time.Time `json:"session_date"` -} - -// The types Programs answers core with are the api's, so the store returns -// them as they are. -type ( - ResidentTransferProgramConflicts = api.ResidentTransferProgramConflicts - ProgramEngagementOverview = api.ProgramEngagementOverview - ProgramCompletionRank = api.ProgramCompletionRank - SecondProgramEnrollmentRow = api.SecondProgramEnrollmentRow - ProgramCompletionMatrixCell = api.ProgramCompletionMatrixCell - ProgramLoadBucket = api.ProgramLoadBucket - ProgramLoadFacilityRow = api.ProgramLoadFacilityRow - ProgramLoadDistribution = api.ProgramLoadDistribution - ProgramTypeEnrollment = api.ProgramTypeEnrollment - ClassDashboardMetrics = api.ClassDashboardMetrics - FacilityHealthSummary = api.FacilityHealthSummary -) - -// Classes is zero for class deletes; populated for programs to count classes whose own children block. -// Events and History are informational and never block: scheduled sessions are -// always created with a class, and change-log/history rows are derivative audit -// artifacts. Both are cleaned up alongside the parent on delete. -// NonDeletableStatus is set on class-only checks when the class is in a status -// other than Scheduled (Active/Completed/Cancelled/Paused), which blocks delete. -type DeleteBlockingChildren struct { - Classes int64 `json:"classes"` - Enrollments int64 `json:"enrollments"` - Events int64 `json:"events"` - Completions int64 `json:"completions"` - AttendanceFlags int64 `json:"attendance_flags"` - History int64 `json:"history"` - NonDeletableStatus string `json:"non_deletable_status,omitempty"` -} - -func (b DeleteBlockingChildren) HasAny() bool { - return b.Classes > 0 || - b.Enrollments > 0 || - b.Completions > 0 || - b.AttendanceFlags > 0 || - b.NonDeletableStatus != "" -} - -type Room struct { - DatabaseFields - FacilityID uint `json:"facility_id" gorm:"not null"` - Name string `json:"name" gorm:"size:255;not null" validate:"required,max=255"` - - Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID;references:ID"` -} - -func (Room) TableName() string { - return "rooms" -} - -type RoomBooking struct { - RoomID uint - EventID uint - ClassID uint - StartTime time.Time - EndTime time.Time - IsOverride bool -} - -type ConflictCheckRequest struct { - FacilityID uint - RoomID uint - InstructorID uint - RecurrenceRule string - Duration string - ExcludeEventID *uint - ExcludeClassID *uint -} - -const ( - ConflictTypeRoom = "room" - ConflictTypeInstructor = "instructor" -) - -type RoomConflict struct { - ConflictingEventID uint `json:"conflicting_event_id"` - ConflictingClassID uint `json:"conflicting_class_id"` - ClassName string `json:"class_name"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - ConflictType string `json:"conflict_type"` -} - -type ResidentProgramClassInfo struct { - ProgramName string `json:"program_name"` - ClassName string `json:"class_name"` - Status ClassStatus `json:"status"` - EnrollmentStatus ProgramEnrollmentStatus `json:"enrollment_status"` - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` - AttendancePercentage string `json:"attendance_percentage"` - CohortID uint `json:"cohort_id"` - ProgramID uint `json:"program_id"` - EnrollmentID uint `json:"enrollment_id"` - PresentAttendance int `json:"present_attendance"` - AbsentAttendance int `json:"absent_attendance"` - CreditTypes string `json:"credit_types"` - UpdatedAt string `json:"updated_at"` - ChangeReason string `json:"change_reason"` - Schedule string `json:"schedule"` - RecurrenceRule string `json:"-"` - IsCanvas bool `json:"is_canvas"` - Source string `json:"source,omitempty"` -} - -// calculates the attendance percentage, sets the feild AttendancePercentage and returns it's value -func (rpc *ResidentProgramClassInfo) CalculateAttendancePercentage() string { - present := rpc.PresentAttendance - absent := rpc.AbsentAttendance - total := present + absent - - if total == 0 { - rpc.AttendancePercentage = "--" - } else { - pct := float64(present) / float64(total) * 100 - rpc.AttendancePercentage = fmt.Sprintf("%.0f%%", pct) - } - return rpc.AttendancePercentage -} - -type WeeklyAttendanceTrend struct { - Week string `json:"week"` - Rate float64 `json:"rate"` -} - -type WeeklyAttendanceRow struct { - WeekStart time.Time - PresentCount float64 - TotalCount float64 -} - -type ResidentAttendanceCSVData struct { - ProgramName string `json:"program_name" gorm:"column:program_name"` - ClassName string `json:"class_name" gorm:"column:class_name"` - SessionDate string `json:"session_date" gorm:"column:session_date"` - AttendanceStatus string `json:"attendance_status" gorm:"column:attendance_status"` - Note string `json:"note" gorm:"column:note"` -} - -func ResidentAttendanceToCSVFormat(data []ResidentAttendanceCSVData) ([][]string, error) { - csvData := [][]string{{"Program Name", "Class Name", "Session Date", "Attendance Status", "Note"}} - - for _, record := range data { - statusDisplay := "" - switch record.AttendanceStatus { - case "present": - statusDisplay = "Present" - case "absent_excused": - statusDisplay = "Absent Excused" - case "absent_unexcused": - statusDisplay = "Absent Unexcused" - default: - statusDisplay = "No Record" - } - - row := []string{ - record.ProgramName, - record.ClassName, - record.SessionDate, - statusDisplay, - record.Note, - } - csvData = append(csvData, row) - } - return csvData, nil -} diff --git a/backend/src/modules/programs/module.go b/backend/src/modules/programs/module.go deleted file mode 100644 index b78d28b1f..000000000 --- a/backend/src/modules/programs/module.go +++ /dev/null @@ -1,173 +0,0 @@ -// Package programs is the Programs feature module: programs, classes, -// enrollments, events and attendance, and the Canvas and Essential Education -// classes shown alongside them. Its code moved here from core in EN-194. -package programs - -import ( - "context" - "embed" - "fmt" - "io/fs" - "os" - "sync" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - - "github.com/nats-io/nats.go" - log "github.com/sirupsen/logrus" - - "github.com/UnlockedLabs/unlocked-contracts/coreapi" - "github.com/UnlockedLabs/unlocked-contracts/providerplatforms" -) - -//go:embed migrations -var migrations embed.FS - -// Module is the Programs module. The registry gives it the Provider Platforms -// service's client, which it asks about providers and the users linked to them. -type Module struct { - ProviderPlatforms providerplatforms.ProviderPlatforms -} - -var ( - _ module.Module = Module{} - _ module.Schema = Module{} - _ module.TestModels = Module{} -) - -func (Module) Name() string { return "programs" } - -// Schema is the Postgres schema that holds Programs' tables. -func (Module) Schema() string { return "programs" } - -// server is what the routes share. -type server struct { - store *Store - Client module.HTTPClient - providerPlatforms providerplatforms.ProviderPlatforms - facilityFeatures module.Features - reports module.Reports - // buckets cache the Canvas programs, classes and events. They are nil - // without NATS (the tests). - buckets map[string]nats.KeyValue - // canvasInflight holds the Canvas cache keys being warmed, so each is - // fetched once at a time. - canvasInflight sync.Map -} - -// newServer is the Programs server for deps. A failure to set up NATS stops -// the server, as it did when core set it up. -func (m Module) newServer(deps module.Deps) *server { - srv := &server{ - store: &Store{deps.DB}, - Client: deps.HTTP, - providerPlatforms: m.ProviderPlatforms, - facilityFeatures: deps.Features, - reports: deps.Reports, - } - if deps.NATS != nil { - buckets, err := canvasBuckets(deps.NATS) - if err != nil { - log.Fatalf("Failed to setup JetStream KV store: %v", err) - } - srv.buckets = buckets - } - return srv -} - -// Register adds the Programs routes. -func (m Module) Register(r module.Router, deps module.Deps) { - srv := m.newServer(deps) - for _, rt := range srv.routes() { - r.Handle(rt) - } -} - -// RegisterJobs schedules class activation (jobs.go). -func (Module) RegisterJobs(s module.Scheduler, deps module.Deps) { - scheduleJobs(s, &Store{deps.DB}, os.Getenv("APP_ENV") == "dev") -} - -func (Module) Migrations() fs.FS { return migrations } - -// LiveProviders is the Canvas and Essential Education code Provider Platforms -// needs, until Provider Platforms fetches and -// caches the providers' classes itself. -func (m Module) LiveProviders(deps module.Deps) coreapi.LiveProviders { - return liveProviders{m.newServer(deps)} -} - -// The NATS key-value buckets for the Canvas caches. -const ( - CanvasPrograms = "canvas_programs" - CanvasClasses = "canvas_classes" - CanvasEvents = "canvas_events" -) - -// canvasBuckets opens the Canvas cache buckets, creating them on first start. -func canvasBuckets(nc *nats.Conn) (map[string]nats.KeyValue, error) { - js, err := nc.JetStream() - if err != nil { - return nil, fmt.Errorf("initialize JetStream: %w", err) - } - buckets := map[string]nats.KeyValue{} - for _, bucket := range []string{CanvasPrograms, CanvasClasses, CanvasEvents} { - kv, err := js.KeyValue(bucket) - if err != nil { - kv, err = js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 1, TTL: 5 * time.Minute}) - if err != nil { - return nil, fmt.Errorf("create bucket %s: %w", bucket, err) - } - } - buckets[bucket] = kv - } - return buckets, nil -} - -// TestModels is Programs' tables, for core's SQLite test database. A class -// comes before its cohorts, so the cohort's foreign key has a table to point -// at. -func (Module) TestModels() []any { - return []any{ - &Program{}, - &ProgramCreditType{}, - &ProgramType{}, - &ProgramClass{}, - &ProgramClassCreditType{}, - &ProgramClassCohort{}, - &FacilitiesPrograms{}, - &ProgramClassEvent{}, - &ProgramClassEnrollment{}, - &ClassCompletion{}, - &ProgramClassEventOverride{}, - &ProgramClassEventAttendance{}, - &ChangeLogEntry{}, - &ProgramClassesHistory{}, - &Room{}, - } -} - -// liveProviders answers Provider Platforms' questions about Essential -// Education's users and Canvas's cached programs. -type liveProviders struct{ srv *server } - -func (l liveProviders) Users(ctx context.Context, providerID uint) ([]coreapi.ProviderUser, bool, error) { - provider, err := l.srv.providerByID(ctx, providerID) - if err != nil { - return nil, false, err - } - lister, ok := l.srv.liveUserListerFor(provider) - if !ok { - return nil, false, nil - } - users, err := lister.ListUsers(ctx) - if err != nil { - return nil, true, err - } - return users, true, nil -} - -func (l liveProviders) MappingsChanged(providerID, userID uint) { - l.srv.invalidateCanvasProgramCache(providerID, int(userID)) -} diff --git a/backend/src/modules/programs/program.go b/backend/src/modules/programs/program.go deleted file mode 100644 index 313d0d7f1..000000000 --- a/backend/src/modules/programs/program.go +++ /dev/null @@ -1,267 +0,0 @@ -package programs - -import ( - "fmt" - "strconv" - "strings" - "time" - - "gorm.io/gorm" -) - -type FundingType string -type ProgType string -type CreditType string - -const ( - CanvasProgramIDOffset = uint(100_000_000) - CanvasClassIDOffset = uint(100_000_000) - // CanvasFacilityClassIDOffset marks facility-scoped synthetic canvas class IDs. - // Encoding: offset + facilityID×1_000_000_000 + providerID×1_000_000 + rawCourseID - // Limits: facilityID 0–999, providerID 0–999, rawCourseID 0–999_999. - // Larger than CanvasClassIDOffset so existing ">= CanvasClassIDOffset" guards still fire. - CanvasFacilityClassIDOffset = uint(1_000_000_000_000) -) - -const ( - //funding types - FederalGrants FundingType = "Federal_Grants" - StateGrants FundingType = "State_Grants" - NonProfitOrgs FundingType = "Nonprofit_Organizations" - EduGrants FundingType = "Educational_Grants" - InmateWelfare FundingType = "Inmate_Welfare_Funds" - Other FundingType = "Other" - //program types - Educational ProgType = "Educational" - Vocational ProgType = "Vocational" - MentalHealth ProgType = "Mental_Health_Behavioral" - Religious ProgType = "Religious_Faith-Based" - ReEntry ProgType = "Re-Entry" - Therapeutic ProgType = "Therapeutic" - LifeSkills ProgType = "Life_Skills" - //credit types - Completion CreditType = "Completion" - Participation CreditType = "Participation" - EarnedTime CreditType = "Earned-time" - Education CreditType = "Education" -) - -func (p ProgType) HumanReadable() string { - switch p { - case Educational: - return "Educational" - case Vocational: - return "Vocational" - case MentalHealth: - return "Mental Health/Behavioral" - case Religious: - return "Religious/Faith-Based" - case ReEntry: - return "Re-Entry" - case Therapeutic: - return "Therapeutic" - case LifeSkills: - return "Life Skills" - default: - return string(p) - } -} - -func (f FundingType) HumanReadable() string { - switch f { - case FederalGrants: - return "Federal Grants" - case StateGrants: - return "State Grants" - case NonProfitOrgs: - return "Nonprofit Organizations" - case EduGrants: - return "Educational Grants" - case InmateWelfare: - return "Inmate Welfare Funds" - case Other: - return "Other" - default: - return string(f) - } -} - -var AllFundingTypes = []FundingType{ - FederalGrants, StateGrants, NonProfitOrgs, EduGrants, InmateWelfare, Other, -} - -var AllProgTypes = []ProgType{ - Educational, Vocational, MentalHealth, Religious, ReEntry, Therapeutic, LifeSkills, -} - -var AllCreditTypes = []CreditType{ - Completion, Participation, EarnedTime, Education, -} - -type Program struct { - DatabaseFields - Name string `json:"name" gorm:"not null;unique" validate:"required,max=255"` - Description string `json:"description" gorm:"not null" validate:"required,max=255"` - FundingType FundingType `json:"funding_type" gorm:"type:funding_type" validate:"required"` - IsActive bool `json:"is_active" gorm:"not null"` - IsFavorited bool `json:"is_favorited" gorm:"-"` - ArchivedAt *time.Time `json:"archived_at"` - - HasProgramCompletion bool `json:"has_program_completion" gorm:"not null;default:false"` - - ProgramTypes []ProgramType `json:"program_types" gorm:"foreignKey:ProgramID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` - ProgramCreditTypes []ProgramCreditType `json:"credit_types" gorm:"foreignKey:ProgramID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` - Facilities []Facility `json:"facilities" gorm:"-"` //preserve original json key - FacilitiesPrograms []FacilitiesPrograms `json:"-" gorm:"foreignKey:ProgramID;references:ID"` //gorm had issues with many2many - Classes []ProgramClass `json:"-" gorm:"foreignKey:ProgramID;references:ID"` -} - -func (Program) TableName() string { return "programs" } - -type ProgramType struct { - ProgramType ProgType `json:"program_type" gorm:"primaryKey;type:program_type" validate:"required"` - ProgramID uint `json:"program_id" gorm:"primaryKey;not null" validate:"required"` - - Program *Program `json:"program" gorm:"foreignKey:ProgramID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` -} - -func (ProgramType) TableName() string { return "program_types" } - -type ProgramCreditType struct { - CreditType CreditType `json:"credit_type" gorm:"primaryKey;type:credit_type" validate:"required"` - ProgramID uint `json:"program_id" gorm:"primaryKey;not null" validate:"required"` - - Program *Program `json:"program" gorm:"foreignKey:ProgramID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` -} - -func (ProgramCreditType) TableName() string { return "program_credit_types" } - -type ProgramTypeInfo struct { - ProgramTypes []ProgType - ProgramCreditTypes []CreditType -} - -type FacilitiesPrograms struct { - DatabaseFields - ProgramID uint `json:"program_id"` - FacilityID uint `json:"facility_id"` - ProgramOwner *string `json:"program_owner" gorm:"size:255;column:program_owner" validate:"max=255"` - - Program *Program `json:"-" gorm:"foreignKey:ProgramID;references:ID"` - Facility *Facility `json:"-" gorm:"foreignKey:FacilityID;references:ID"` -} - -func (FacilitiesPrograms) TableName() string { return "facilities_programs" } - -func (fp *FacilitiesPrograms) BeforeCreate(tx *gorm.DB) error { - if err := fp.DatabaseFields.BeforeCreate(tx); err != nil { - return err - } - fp.UpdateUserID = nil - return nil -} - -type ProgramsFacilitiesStats struct { - TotalPrograms *int64 `json:"total_programs"` - AvgActiveProgramsPerFacility *float64 `json:"avg_active_programs_per_facility"` - TotalEnrollments *int64 `json:"total_enrollments"` - AttendanceRate *float64 `json:"attendance_rate"` - CompletionRate *float64 `json:"completion_rate"` -} - -type ProgramsOverviewTable struct { - ProgramID uint `json:"program_id"` - ProgramName string `json:"program_name"` - Description string `json:"description"` - ArchivedAt *string `json:"archived_at"` - TotalActiveFacilities *int64 `json:"total_active_facilities"` - TotalEnrollments *int64 `json:"total_enrollments"` - TotalActiveEnrollments *int64 `json:"total_active_enrollments"` - TotalClasses *int64 `json:"total_classes"` - TotalActiveClasses *int64 `json:"total_active_classes"` - TotalCancelledClasses *int64 `json:"total_cancelled_classes"` - TotalCapacity *int64 `json:"total_capacity"` - TotalFilledSeats *int64 `json:"total_filled_seats"` - CompletionRate *float64 `json:"completion_rate"` - AttendanceRate *float64 `json:"attendance_rate"` - // The numerator and denominator CompletionRate was built from, so a client - // aggregating across programs can weight by the real rate-eligible - // population instead of reconstructing one from TotalEnrollments. - RateEligibleCompletions *int64 `json:"rate_eligible_completions"` - RateEligibleEnrollments *int64 `json:"rate_eligible_enrollments"` - Types string `json:"program_types" gorm:"column:program_types"` - CreditTypes string `json:"credit_types"` - FundingType string `json:"funding_type"` - Status bool `json:"status"` - Source string `json:"source"` - Loading bool `json:"loading,omitempty"` -} - -type ProgramOverviewResponse struct { - Program `gorm:"-"` - ActiveResidents int `json:"active_residents"` - ActiveEnrollments int `json:"active_enrollments"` - Completions int `json:"completions"` - TotalEnrollments int `json:"total_enrollments"` - CompletionRate *float64 `json:"completion_rate"` - AttendanceRate *float64 `json:"attendance_rate"` - ActiveClassFacilityIDs []int `json:"active_class_facility_ids" gorm:"-"` - Source string `json:"source,omitempty" gorm:"-"` - Loading bool `json:"loading,omitempty"` -} - -type ProgramCSVData struct { - FacilityName string `json:"facility_name" gorm:"column:facility_name"` - ProgramName string `json:"program_name" gorm:"column:program_name"` - ClassName string `json:"class_name" gorm:"column:class_name"` - InstructorName string `json:"instructor_name" gorm:"column:instructor_name"` - UnlockEdID uint `json:"unlock_ed_id" gorm:"column:unlock_ed_id"` - NameFirst string `json:"name_first" gorm:"column:name_first"` - NameLast string `json:"name_last" gorm:"column:name_last"` - ResidentID string `json:"resident_id" gorm:"column:resident_id"` - EnrollmentDate time.Time `json:"enrollment_date" gorm:"column:enrollment_date"` - EndDate *time.Time `json:"end_date" gorm:"column:end_date"` - EndStatus string `json:"end_status" gorm:"column:end_status"` - AttendancePercentage float64 `json:"attendance_percentage" gorm:"column:attendance_percentage"` -} - -func ProgramDataToCSVFormat(data []ProgramCSVData) ([][]string, error) { - csvData := [][]string{{"Facility Name", "Program Name", "Class Name", "Instructor Name", "Last Name", "First Name", "Resident ID", "UnlockEd ID", "Enrollment Date", "End Date", "End Status", "Attendance Percentage"}} - for _, record := range data { - var endDateStr string - if record.EndDate != nil && !record.EndDate.IsZero() { - endDateStr = record.EndDate.Format("2006-01-02") - } else { - endDateStr = "" - } - - row := []string{ - record.FacilityName, - record.ProgramName, - record.ClassName, - record.InstructorName, - record.NameLast, - record.NameFirst, - record.ResidentID, - strconv.Itoa(int(record.UnlockEdID)), - record.EnrollmentDate.Format("2006-01-02"), - endDateStr, - record.EndStatus, - fmt.Sprintf("%.2f%%", record.AttendancePercentage), - } - csvData = append(csvData, row) - } - return csvData, nil -} - -func (p *Program) GetUniqueCreditTypeString() string { - uniqueCreditTypes := make(map[string]struct{}) - for _, ct := range p.ProgramCreditTypes { - uniqueCreditTypes[string(ct.CreditType)] = struct{}{} - } - keys := make([]string, 0, len(uniqueCreditTypes)) - for k := range uniqueCreditTypes { - keys = append(keys, k) - } - return strings.Join(keys, ",") -} diff --git a/backend/src/modules/programs/program_class_tier_handler.go b/backend/src/modules/programs/program_class_tier_handler.go deleted file mode 100644 index cf405d9e3..000000000 --- a/backend/src/modules/programs/program_class_tier_handler.go +++ /dev/null @@ -1,155 +0,0 @@ -package programs - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "strconv" -) - -var ( - errProgramRequired = errors.New("program_id is required to create a class") - errCohortFieldOnClass = errors.New("status, capacity and dates belong to a cohort, not a class -- update the cohort instead") -) - -// handleIndexProgramClasses lists the class tier with its cohorts rolled up. Scoped to a -// program with ?program_id=, otherwise to the caller's facility. -func (srv *server) handleIndexProgramClasses(w http.ResponseWriter, r *http.Request, log sLog) error { - args := getQueryContext(r) - - if raw := r.URL.Query().Get("program_id"); raw != "" { - programID, err := strconv.Atoi(raw) - if err != nil { - return newInvalidIdServiceError(err, "program_id") - } - classes, err := srv.store.GetClassesForProgram(programID, &args) - if err != nil { - log.add("program_id", programID) - return newDatabaseServiceError(err) - } - return writePaginatedResponse(w, http.StatusOK, classes, args.IntoMeta()) - } - - classes, err := srv.store.GetClassesForFacility(&args) - if err != nil { - return newDatabaseServiceError(err) - } - return writePaginatedResponse(w, http.StatusOK, classes, args.IntoMeta()) -} - -// handleGetProgramClass returns one class with its rollups, cohorts and credit types. -func (srv *server) handleGetProgramClass(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - args := getQueryContext(r) - class, err := srv.store.GetClassByID(id, &args) - if err != nil { - log.add("class_id", id) - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, class) -} - -// programClassRequest is the wire shape for create/update. -// -// CreditTypes is a POINTER to a slice on purpose, and the distinction is load-bearing: -// -// omitted (nil) -> leave the class's credit types alone -// [] -> CLEAR the override, so the class inherits its program's types -// ["Completion"] -> set an explicit override -// -// Collapsing nil and [] would make "inherit" unreachable through the API. -type programClassRequest struct { - ProgramClass - CreditTypes *[]CreditType `json:"credit_types"` -} - -func (srv *server) handleCreateProgramClass(w http.ResponseWriter, r *http.Request, log sLog) error { - body, err := io.ReadAll(r.Body) - if err != nil { - return newJSONReqBodyServiceError(err) - } - var req programClassRequest - if err := json.Unmarshal(body, &req); err != nil { - return newJSONReqBodyServiceError(err) - } - - facilityID, err := requireFacilityID(r) - if err != nil { - return err - } - class := req.ProgramClass - class.FacilityID = facilityID - class.CreateUserID = UintPtr(claimsFrom(r).UserID) - - if class.ProgramID == 0 { - return newBadRequestServiceError(errProgramRequired, "program_id") - } - - var creditTypes []CreditType - if req.CreditTypes != nil { - creditTypes = *req.CreditTypes - } - if err := srv.store.CreateClass(&class, creditTypes); err != nil { - return newDatabaseServiceError(err) - } - log.add("class_id", class.ID) - return writeJsonResponse(w, http.StatusCreated, class) -} - -// handleUpdateProgramClass updates class-level fields only. Status, capacity and dates -// are cohort concerns and are rejected rather than silently ignored -- a caller sending -// them almost certainly means to update a cohort and should hear about it. -func (srv *server) handleUpdateProgramClass(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "class ID") - } - body, err := io.ReadAll(r.Body) - if err != nil { - return newJSONReqBodyServiceError(err) - } - - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return newJSONReqBodyServiceError(err) - } - - for _, cohortOnly := range []string{"status", "capacity", "start_dt", "end_dt"} { - if _, present := raw[cohortOnly]; present { - return newBadRequestServiceError(errCohortFieldOnClass, cohortOnly) - } - } - - updates := map[string]any{} - for _, f := range []string{"name", "description", "credit_hours", "archived_at"} { - if v, present := raw[f]; present { - updates[f] = v - } - } - updates["update_user_id"] = claimsFrom(r).UserID - - var creditTypes []CreditType - if v, present := raw["credit_types"]; present { - encoded, err := json.Marshal(v) - if err != nil { - return newJSONReqBodyServiceError(err) - } - // non-nil even when empty, so "[]" reaches the DB layer as "clear the override" - creditTypes = []CreditType{} - if err := json.Unmarshal(encoded, &creditTypes); err != nil { - return newJSONReqBodyServiceError(err) - } - } - - class, err := srv.store.UpdateClass(id, updates, creditTypes) - if err != nil { - log.add("class_id", id) - return newDatabaseServiceError(err) - } - log.add("class_id", id) - return writeJsonResponse(w, http.StatusOK, class) -} diff --git a/backend/src/modules/programs/program_classes.go b/backend/src/modules/programs/program_classes.go deleted file mode 100644 index fe807ec8b..000000000 --- a/backend/src/modules/programs/program_classes.go +++ /dev/null @@ -1,487 +0,0 @@ -package programs - -import ( - "encoding/json" - "fmt" - "strings" - "time" - - "gorm.io/gorm" -) - -type ClassStatus string - -const ( - Scheduled ClassStatus = "Scheduled" - Active ClassStatus = "Active" - Cancelled ClassStatus = "Cancelled" - Completed ClassStatus = "Completed" - Paused ClassStatus = "Paused" -) - -type ProgramClassCohort struct { - DatabaseFields - ClassID uint `json:"program_class_id" gorm:"not null"` - ClassName string `json:"class_name" gorm:"->"` - ProgramID uint `json:"program_id" gorm:"not null"` - FacilityID uint `json:"facility_id" gorm:"not null"` - Capacity int64 `json:"capacity" gorm:"not null"` - InstructorID *uint `json:"instructor_id" gorm:"-"` - UpdateInstructor bool `json:"-" gorm:"-"` - Description string `json:"description" gorm:"not null" validate:"required,max=255"` - ArchivedAt *time.Time `json:"archived_at"` - StartDt time.Time `gorm:"type:date" json:"start_dt"` - EndDt *time.Time `gorm:"type:date" json:"end_dt"` - Status ClassStatus `json:"status" gorm:"type:class_status" validate:"required"` - CreditHours *int64 `json:"credit_hours"` - Enrolled int64 `json:"enrolled" gorm:"-"` - Completed int64 `json:"completed" gorm:"-"` - IsCanvas bool `json:"is_canvas" gorm:"-"` - Source string `json:"source,omitempty" gorm:"-"` - Loading bool `json:"loading,omitempty" gorm:"-"` - CanvasTimezone string `json:"canvas_timezone,omitempty" gorm:"-"` - - Class *ProgramClass `json:"program_class" gorm:"foreignKey:ClassID;references:ID"` - Program *Program `json:"program" gorm:"foreignKey:ProgramID;references:ID"` - Enrollments []ProgramClassEnrollment `json:"enrollments" gorm:"foreignKey:CohortID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - Facility *Facility `json:"facility" gorm:"foreignKey:FacilityID;references:ID"` - Events []ProgramClassEvent `json:"events" gorm:"foreignKey:CohortID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - FacilityProg *FacilitiesPrograms `json:"facility_program" gorm:"foreignKey:ProgramID;references:ProgramID"` -} - -func (ProgramClassCohort) TableName() string { return "program_class_cohorts" } - -type ProgramClass struct { - DatabaseFields - ProgramID uint `json:"program_id" gorm:"not null"` - FacilityID uint `json:"facility_id" gorm:"not null"` - Name string `json:"name" gorm:"size:255" validate:"required,max=255"` - Description *string `json:"description"` - CreditHours *int64 `json:"credit_hours"` - ArchivedAt *time.Time `json:"archived_at"` - - // Rollups across cohorts. Computed, never stored -- see program_classes_store.go. - CohortCount int64 `json:"cohort_count" gorm:"-"` - ActiveCohorts int64 `json:"active_cohorts" gorm:"-"` - ScheduledCohorts int64 `json:"scheduled_cohorts" gorm:"-"` - CompletedCohorts int64 `json:"completed_cohorts" gorm:"-"` - Enrolled int64 `json:"enrolled" gorm:"-"` - Capacity int64 `json:"capacity" gorm:"-"` - Completed int64 `json:"completed" gorm:"-"` - - Program *Program `json:"program" gorm:"foreignKey:ProgramID;references:ID"` - Facility *Facility `json:"facility" gorm:"foreignKey:FacilityID;references:ID"` - Cohorts []ProgramClassCohort `json:"cohorts" gorm:"foreignKey:ClassID;references:ID"` - CreditTypes []ProgramClassCreditType `json:"credit_types" gorm:"foreignKey:ClassID;references:ID"` -} - -func (ProgramClass) TableName() string { return "program_classes" } - -type ProgramClassCreditType struct { - ClassID uint `json:"program_class_id" gorm:"primaryKey;not null"` - CreditType CreditType `json:"credit_type" gorm:"primaryKey;type:credit_type;not null"` -} - -func (ProgramClassCreditType) TableName() string { return "program_class_credit_types" } - -// CreditHoursOrDefault resolves the two-level credit_hours: the cohort's own value wins, -// falling back to its class's default. Mirrors COALESCE(cohort, class) in SQL. -// Returns nil only when neither level has a value. -func (c *ProgramClassCohort) CreditHoursOrDefault() *int64 { - if c.CreditHours != nil { - return c.CreditHours - } - if c.Class != nil { - return c.Class.CreditHours - } - return nil -} - -// CreditTypesOrInherited applies the empty-means-inherit rule. Pass the program's credit -// types; they are returned unchanged when the class defines no override of its own. -func (pc *ProgramClass) CreditTypesOrInherited(programTypes []CreditType) []CreditType { - if len(pc.CreditTypes) == 0 { - return programTypes - } - out := make([]CreditType, 0, len(pc.CreditTypes)) - for _, ct := range pc.CreditTypes { - out = append(out, ct.CreditType) - } - return out -} - -type TodaysScheduleItem struct { - CohortID uint `json:"cohort_id"` - ClassName string `json:"class_name"` - InstructorName string `json:"instructor_name"` - FacilityID uint `json:"facility_id"` - FacilityName string `json:"facility_name"` - EventID uint `json:"event_id"` - Date string `json:"date"` - StartTime string `json:"start_time"` - Room string `json:"room"` - HasAttendance bool `json:"has_attendance"` - EnrolledCount int `json:"enrolled_count"` -} - -func (c *ProgramClassCohort) BeforeCreate(tx *gorm.DB) error { - if err := c.DatabaseFields.BeforeCreate(tx); err != nil { - return err - } - c.UpdateUserID = nil - - // A cohort with no parent class is meaningless -- class_id is NOT NULL and the - // certificate lives on the class. So when no class is given, create one from this - // cohort's own attributes. - // - // This is deliberate, not a convenience: today's UI has a single "create a class" - // action that creates what we now call a cohort, and it stays that way until the - // Phase 4 frontend splits the two. Auto-creating the parent means that action keeps - // working AND produces correctly-shaped two-tier data (one class, one cohort) instead - // of an orphan. Callers that want to attach to an existing class set ClassID. - if c.ClassID == 0 { - // The cohort has no name to lend, so the parent takes the PROGRAM's name. That is - // only a fallback: every UI path picks or creates a class explicitly and sets - // ClassID, so this fires for legacy/API callers and test fixtures. Naming it after - // the program keeps such a class recognisable rather than blank. - var programName string - if err := tx.Session(&gorm.Session{NewDB: true}). - Model(&Program{}). - Select("name"). - Where("id = ?", c.ProgramID). - Scan(&programName).Error; err != nil { - return fmt.Errorf("resolving program %d name for auto-created class: %w", c.ProgramID, err) - } - if programName == "" { - return fmt.Errorf("cannot auto-create a class for cohort: program %d not found", c.ProgramID) - } - parent := ProgramClass{ - ProgramID: c.ProgramID, - FacilityID: c.FacilityID, - Name: programName, - Description: NilIfBlank(c.Description), - CreditHours: c.CreditHours, - ArchivedAt: c.ArchivedAt, - DatabaseFields: DatabaseFields{ - CreateUserID: c.CreateUserID, - }, - } - // NewDB is required, not optional: tx's Statement is bound to the COHORT schema, and - // reusing it to create a different model makes GORM index the parent's fields - // against the cohort's schema and panic in reflect. - if err := tx.Session(&gorm.Session{NewDB: true, SkipHooks: true}).Create(&parent).Error; err != nil { - return fmt.Errorf("creating parent class %q for cohort: %w", programName, err) - } - c.ClassID = parent.ID - } - return nil -} - -type ProgramClassEnrollment struct { - DatabaseFields - CohortID uint `json:"cohort_id" gorm:"column:cohort_id;not null"` - ClassID uint `json:"program_class_id" gorm:"not null"` - UserID uint `json:"user_id" gorm:"not null"` - EnrollmentStatus ProgramEnrollmentStatus `json:"enrollment_status" gorm:"size:255" validate:"max=255"` - ChangeReason *string `json:"change_reason" gorm:"size:255" validate:"max=255"` - EnrolledAt *time.Time `json:"enrolled_at"` - EnrollmentEndedAt *time.Time `json:"enrollment_ended_at"` - - User *User `json:"user" gorm:"foreignKey:UserID;references:ID"` - Cohort *ProgramClassCohort `json:"cohort" gorm:"foreignKey:CohortID;references:ID"` - Class *ProgramClass `json:"program_class" gorm:"foreignKey:ClassID;references:ID"` -} - -func (ProgramClassEnrollment) TableName() string { return "program_class_enrollments" } - -// BeforeCreate hook that runs to set enrolled_at when enrolling in an Active COHORT. -// Status lives on the cohort, so this reads the cohort -- not the class. -func (e *ProgramClassEnrollment) BeforeCreate(tx *gorm.DB) (err error) { - if err := e.DatabaseFields.BeforeCreate(tx); err != nil { - return err - } - - // ClassID is a denormalization of the cohort's parent, and the column is NOT NULL with - // no database default. Resolving it here rather than at each call site means no caller - // can forget it -- and, more importantly, means no caller can pass a COHORT id as - // ClassID, which would compile cleanly and silently corrupt the class rollups. - if e.ClassID == 0 && e.CohortID != 0 { - var classID uint - if err := tx.Model(&ProgramClassCohort{}). - Select("class_id"). - Where("id = ?", e.CohortID). - Scan(&classID).Error; err != nil { - return err - } - if classID == 0 { - return fmt.Errorf("cohort %d has no parent class; cannot enroll", e.CohortID) - } - e.ClassID = classID - } - - // allow calling code to override - if e.EnrolledAt != nil { - return nil - } - - var classStatus ClassStatus - if e.CohortID != 0 { - if err := tx.Model(&ProgramClassCohort{}). - Select("status"). - Where("id = ?", e.CohortID). - Scan(&classStatus).Error; err != nil { - return err - } - } - - if classStatus == Active && e.EnrollmentStatus == Enrolled { - tx.Statement.SetColumn("enrolled_at", time.Now().UTC()) - return nil - } - - return nil -} - -// BeforeUpdate hook that runs to set enrolled_at if enrolling in an Active class or enrollment_ended_at if entering a terminal state while class is Active|Paused. -func (e *ProgramClassEnrollment) BeforeUpdate(tx *gorm.DB) (err error) { - if !tx.Statement.Changed("enrollment_status") { - return nil - } - - var newEnrollmentStatus ProgramEnrollmentStatus - if m, ok := tx.Statement.Dest.(map[string]interface{}); ok { - if v, ok := m["enrollment_status"]; ok { - switch val := v.(type) { - case string: - newEnrollmentStatus = ProgramEnrollmentStatus(val) - case ProgramEnrollmentStatus: - newEnrollmentStatus = val - default: - return fmt.Errorf("unknown type %T", val) - } - } - } - - // ⚠️ 'cohort_id' is a runtime contract the compiler cannot see. Callers that update - // an enrollment's status must Set("cohort_id", ...) -- see withCohortID in - // class_enrollments_store.go. Absent it, cohortID stays 0 and the status - // lookup is skipped, so enrolled_at silently never gets set. - var cohortID int - if v, ok := tx.Get("cohort_id"); ok { - switch val := v.(type) { - case int: - cohortID = val - case uint: - cohortID = int(val) - default: - return fmt.Errorf("unexpected type for 'cohort_id': %T", val) - } - } - - var classStatus ClassStatus - if cohortID != 0 { - if err := tx.Model(&ProgramClassCohort{}). - Select("status"). - Where("id = ?", cohortID). - Scan(&classStatus).Error; err != nil { - return err - } - } - - // This likely gets hit when we introduce "Waitlist" as a status - if newEnrollmentStatus == Enrolled && classStatus == Active && !tx.Statement.Changed("enrolled_at") { - tx.Statement.SetColumn("enrolled_at", time.Now().UTC()) - // ? do we need to worry about updating fields to the same value (enrolled -> enrolled)? - } - - // Clear enrollment_ended_at when status becomes Enrolled (reactivation) - if newEnrollmentStatus == Enrolled && (classStatus == Active || classStatus == Paused) { - tx.Statement.SetColumn("enrollment_ended_at", nil) - } - - if IsTerminalEnrollment(newEnrollmentStatus) { - tx.Statement.SetColumn("enrollment_ended_at", time.Now().UTC()) - } - - return nil -} - -func IsTerminalEnrollment(s ProgramEnrollmentStatus) bool { - return s == EnrollmentCancelled || s == EnrollmentCompleted || - strings.HasPrefix(string(s), "Incomplete:") -} - -type ProgramClassDetail struct { - ProgramClassCohort - FacilityName string `json:"facility_name"` - Enrolled int `json:"enrolled"` - HistoricalEnrollments int `json:"historical_enrollments"` - Schedule string `json:"schedule"` - Room string `json:"room"` - AttendanceRate *float64 `json:"attendance_rate"` - Completed int `json:"completed"` - - // Rate-eligible counts apply the same enrollment_ended_at predicate as the - // program-level completion rate in programs_store.go, so a client - // dividing these cannot contradict the server's own number. Completed and - // HistoricalEnrollments above are raw counts with other consumers and are - // deliberately left untightened. - RateEligibleCompletions int `json:"rate_eligible_completions"` - RateEligibleEnrollments int `json:"rate_eligible_enrollments"` -} - -type ProgramEnrollmentStatus string - -const ( - Enrolled ProgramEnrollmentStatus = "Enrolled" - EnrollmentCancelled ProgramEnrollmentStatus = "Cancelled" - EnrollmentCompleted ProgramEnrollmentStatus = "Completed" - EnrollmentIncompleteWithdrawn ProgramEnrollmentStatus = "Incomplete: Withdrawn" - EnrollmentIncompleteDropped ProgramEnrollmentStatus = "Incomplete: Dropped" - EnrollmentIncompleteFailedToComplete ProgramEnrollmentStatus = "Incomplete: Failed to Complete" - EnrollmentIncompleteTransfered ProgramEnrollmentStatus = "Incomplete: Transfered" - EnrollmentIncompleteSegregated ProgramEnrollmentStatus = "Incomplete: Segregated" -) - -// AllEnrollmentStatuses enumerates every value of ProgramEnrollmentStatus, so -// derived status sets (see CompletedOutcomeStatuses) have one place to stay -// in sync with the type's definition instead of hand-copying a subset of it. -var AllEnrollmentStatuses = []ProgramEnrollmentStatus{ - Enrolled, - EnrollmentCancelled, - EnrollmentCompleted, - EnrollmentIncompleteWithdrawn, - EnrollmentIncompleteDropped, - EnrollmentIncompleteFailedToComplete, - EnrollmentIncompleteTransfered, - EnrollmentIncompleteSegregated, -} - -// CompletedOutcomeStatuses are the terminal statuses that represent a real -// completion opportunity, and so are the correct denominator for every -// completion rate and historical-enrollment count. A Cancelled enrollment is -// terminal (IsTerminalEnrollment says so, because it still stamps -// enrollment_ended_at) but never had an opportunity to complete, so counting -// it in a denominator deflates the rate. Derived from IsTerminalEnrollment so -// the two definitions cannot drift apart as they previously did. -var CompletedOutcomeStatuses = func() []ProgramEnrollmentStatus { - statuses := make([]ProgramEnrollmentStatus, 0, len(AllEnrollmentStatuses)) - for _, s := range AllEnrollmentStatuses { - if IsTerminalEnrollment(s) && s != EnrollmentCancelled { - statuses = append(statuses, s) - } - } - return statuses -}() - -type ClassCompletion struct { - DatabaseFields - UserID uint `json:"user_id" gorm:"not null"` - CohortID *uint `json:"cohort_id" gorm:"column:cohort_id"` - ClassID *uint `json:"program_class_id"` - FacilityName string `json:"facility_name" gorm:"not null"` - CreditType string `json:"credit_type" gorm:"not null"` - AdminEmail string `json:"admin_email" gorm:"not null"` - ProgramOwner string `json:"program_owner" gorm:"not null"` - ProgramName string `json:"program_name" gorm:"not null"` - ProgramID *uint `json:"program_id"` - ClassName string `json:"class_name" gorm:"column:class_name"` - CohortStartDt time.Time `json:"class_start_dt" gorm:"column:cohort_start_dt"` - EnrolledOnDt time.Time `json:"enrolled_on_dt"` - - User *User `json:"user" gorm:"foreignKey:UserID;references:ID"` - Cohort *ProgramClassCohort `json:"cohort" gorm:"foreignKey:CohortID;references:ID"` - Class *ProgramClass `json:"program_class" gorm:"foreignKey:ClassID;references:ID"` -} - -func (ClassCompletion) TableName() string { return "class_completions" } - -type ProgramClassesHistory struct { - ID uint `json:"id"` - ParentRefID uint `json:"parent_ref_id"` - NameTable string `json:"table_name" gorm:"column:table_name;size:255"` // cant use TableName because used below - BeforeUpdate json.RawMessage `json:"before_update" gorm:"type:json"` - AfterUpdate json.RawMessage `json:"after_update" gorm:"type:json"` - CreatedAt time.Time `json:"created_at"` -} - -func (ProgramClassesHistory) TableName() string { return "program_classes_history" } - -const ( - TableNamePrograms = "programs" - TableNameClass = "program_classes" - TableNameCohort = "program_class_cohorts" -) - -func (pc *ProgramClassCohort) CannotUpdateClassWithEnrollment(enrollmentStatus ProgramEnrollmentStatus) bool { - isScheduledAndNotCancelled := pc.Status == Scheduled && enrollmentStatus != EnrollmentCancelled - isActiveAndCancelled := pc.Status == Active && enrollmentStatus == EnrollmentCancelled - return pc.CannotUpdateClass() || isScheduledAndNotCancelled || isActiveAndCancelled -} - -func (pc *ProgramClassCohort) CannotUpdateClass() bool { - return pc.Status == Completed || pc.Status == Cancelled || pc.ArchivedAt != nil -} - -func (pc *ProgramClassCohort) GetProgramOwnerOrEmpty() string { - facilityProg := pc.FacilityProg - if facilityProg != nil { - return FormatNullableString(facilityProg.ProgramOwner) - } - return "" -} - -type ConflictDetail struct { - UserID uint `json:"user_id"` - UserName string `json:"user_name"` - ConflictingClass string `json:"conflicting_class"` - ConflictStart time.Time `json:"conflict_start"` - ConflictEnd time.Time `json:"conflict_end"` - ConflictDays []string `json:"conflict_days"` -} - -type BulkCancelSessionsRequest struct { - InstructorID int `json:"instructorId" validate:"gte=0"` - StartDate string `json:"startDate" validate:"required"` - EndDate string `json:"endDate" validate:"required"` - Reason string `json:"reason" validate:"required,min=10,max=255"` -} - -type BulkCancelSessionsResponse struct { - Success bool `json:"success"` - SessionCount int `json:"sessionCount"` - ClassCount int `json:"classCount"` - StudentCount int `json:"studentCount"` - AlreadyCancelledCount int `json:"alreadyCancelledCount"` - Message string `json:"message,omitempty"` - Classes []AffectedClass `json:"classes"` -} - -type AffectedClass struct { - ClassID int `json:"classId"` - ClassName string `json:"className"` - UpcomingSessions int `json:"upcomingSessions"` - CancelledSessions int `json:"cancelledSessions"` - StudentCount int `json:"studentCount"` -} - -type Instructor struct { - ID int `json:"id"` - Username string `json:"username"` - NameFirst string `json:"name_first"` - NameLast string `json:"name_last"` - Email string `json:"email"` -} - -type InstructorClassData struct { - ID int `json:"id"` - Name string `json:"name"` - SessionCount int `json:"sessionCount"` - EnrolledCount int `json:"enrolledCount"` - UpcomingSessions int `json:"upcomingSessions"` - CancelledSessions int `json:"cancelledSessions"` - StartTime string `json:"startTime"` - Duration string `json:"duration"` - Room string `json:"room"` - SessionDates []string `json:"sessionDates" gorm:"-"` -} diff --git a/backend/src/modules/programs/program_classes_store.go b/backend/src/modules/programs/program_classes_store.go deleted file mode 100644 index 49a6becd1..000000000 --- a/backend/src/modules/programs/program_classes_store.go +++ /dev/null @@ -1,1385 +0,0 @@ -package programs - -import ( - "context" - "fmt" - "runtime/debug" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/users" - log "github.com/sirupsen/logrus" - "github.com/teambition/rrule-go" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -type BulkCancelClaims interface { - GetUserID() uint - GetFacilityID() uint - GetTimezone() string -} - -func parseDateRange(startDate, endDate string) (time.Time, time.Time, error) { - start, err := time.Parse("2006-01-02", startDate) - if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("invalid start date format: %w", err) - } - start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) - - end, err := time.Parse("2006-01-02", endDate) - if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("invalid end date format: %w", err) - } - end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, time.UTC) - - return start, end, nil -} - -func (db *Store) GetCohortByID(id int) (*ProgramClassCohort, error) { - content := &ProgramClassCohort{} - // See GetClasses: class_name is the parent CLASS's name, joined in explicitly. - if err := db.Joins("JOIN program_classes pc ON pc.id = program_class_cohorts.class_id"). - Select("program_class_cohorts.*, pc.name AS class_name"). - Preload("Events").Preload("Events.Overrides").Preload("Events.Overrides.Instructor").Preload("Events.Overrides.RoomRef").Preload("Events.RoomRef"). - Preload("Events.Instructor").Preload("Enrollments", func(tx *gorm.DB) *gorm.DB { - return tx.Joins("JOIN users ON users.id = program_class_enrollments.user_id AND users.deleted_at IS NULL") - }). - Preload("Program").Preload("Facility").First(content, "program_class_cohorts.id = ?", id).Error; err != nil { - return nil, newNotFoundDBError(err, "program classes") - } - var enrollments, completed int - - for _, enrolled := range content.Enrollments { - switch enrolled.EnrollmentStatus { - case Enrolled: - enrollments += 1 - case EnrollmentCompleted: - completed += 1 - } - } - content.Enrolled = int64(enrollments) - content.Completed = int64(completed) - return content, nil -} - -func (db *Store) GetCohortsForFacility(args *QueryContext) ([]ProgramClassCohort, error) { - return db.GetClasses(args, &args.FacilityID) -} - -func (db *Store) GetClasses(args *QueryContext, facilityID *uint) ([]ProgramClassCohort, error) { - content := []ProgramClassCohort{} - // class_name is the parent CLASS's name -- the one the UI shows. - // - // ⚠️ EVERY predicate below must be table-qualified. program_classes carries - // archived_at, facility_id AND name too, so an unqualified `archived_at IS NULL` - // is ambiguous once this join exists -- Postgres errors, SQLite silently picks a - // table, so the Go suite passes and dev 500s. - // - // The join is many-to-one (class_id is NOT NULL behind a composite FK), so it cannot - // multiply rows and is safe to apply before the Count. - tx := db.WithContext(args.Ctx).Model(&ProgramClassCohort{}). - Joins("JOIN program_classes pc ON pc.id = program_class_cohorts.class_id"). - Where("program_class_cohorts.archived_at IS NULL") - if facilityID != nil { - tx = tx.Where("program_class_cohorts.facility_id = ?", *facilityID) - } - if args.Search != "" { - // The class's name only -- the cohort's own name is never shown, and the column - // is slated for removal (task #12). - tx = tx.Where("LOWER(pc.name) LIKE ?", args.SearchQuery()) - } - if err := tx.Count(&args.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "program classes") - } - - tx = tx.Select("program_class_cohorts.*, pc.name AS class_name"). - // Events.Instructor populates the event's `instructor_ref`, which is the ONLY - // source for the Instructor column on /classes -- the frontend derives it with - // getInstructorName(cls.events). Without this preload the column renders blank - // and instructor search silently matches nothing. GetCohortByID already has it, - // which is why class detail showed an instructor while the list did not. - Preload("Events").Preload("Events.RoomRef").Preload("Events.Instructor"). - Preload("Enrollments", func(db *gorm.DB) *gorm.DB { - return db.Joins("JOIN users ON users.id = program_class_enrollments.user_id AND users.deleted_at IS NULL") - }). - Preload("Program").Preload("Facility") - if !args.All { - tx = tx.Limit(args.PerPage).Offset(args.CalcOffset()) - } - if err := tx.Find(&content).Error; err != nil { - return nil, newGetRecordsDBError(err, "program classes") - } - for i := range content { - var enrollments, completed int - for _, e := range content[i].Enrollments { - switch e.EnrollmentStatus { - case Enrolled: - enrollments++ - case EnrollmentCompleted: - completed++ - } - } - content[i].Enrolled = int64(enrollments) - content[i].Completed = int64(completed) - } - return content, nil -} - -func (db *Store) CreateProgramClass(content *ProgramClassCohort, conflictReq *ConflictCheckRequest) (*ProgramClassCohort, []RoomConflict, error) { - if err := Validate().Struct(content); err != nil { - return nil, nil, newCreateDBError(err, "create program classes validation error") - } - - if conflictReq == nil { - if err := db.Create(&content).Error; err != nil { - return nil, nil, newCreateDBError(err, "program classes") - } - return content, nil, nil - } - - tx := db.Begin() - if tx.Error != nil { - return nil, nil, newDBError(tx.Error, "unable to start transaction") - } - - conflicts, err := LockAndCheckConflicts(tx, conflictReq) - if err != nil { - tx.Rollback() - return nil, nil, err - } - if len(conflicts) > 0 { - tx.Rollback() - return nil, conflicts, nil - } - - if err := tx.Create(&content).Error; err != nil { - tx.Rollback() - return nil, nil, newCreateDBError(err, "program classes") - } - - if err := tx.Commit().Error; err != nil { - return nil, nil, newDBError(err, "unable to commit transaction") - } - return content, nil, nil -} - -func (db *Store) UpdateProgramClass(content *ProgramClassCohort, id int, conflictReq *ConflictCheckRequest) (*ProgramClassCohort, []RoomConflict, error) { - var allChanges []ChangeLogEntry - existing := &ProgramClassCohort{} - if err := db.Preload("Events").First(existing, "id = ?", id).Error; err != nil { - return nil, nil, newNotFoundDBError(err, "program classes") - } - - trans := db.Begin() - if trans.Error != nil { - return nil, nil, newDBError(trans.Error, "unable to start the database transaction") - } - - if conflictReq != nil { - conflicts, err := LockAndCheckConflicts(trans, conflictReq) - if err != nil { - trans.Rollback() - return nil, nil, err - } - if len(conflicts) > 0 { - trans.Rollback() - return nil, conflicts, nil - } - } - - ignoredFieldNames := []string{"create_user_id", "update_user_id", "enrollments", "facility", "facilities", "events", "facility_program", "program_id", "facility_id", "start_dt", "end_dt", "program", "enrolled", "completed", "archived_at", "instructor", "instructor_id"} - classLogEntries := GenerateChangeLogEntries(existing, content, TableNameCohort, existing.ID, DerefUint(content.UpdateUserID), ignoredFieldNames) - allChanges = append(allChanges, classLogEntries...) - - existingID := existing.ID - - var needsRoomUpdate bool - var newRoomID *uint - var eventID uint - var needsScheduleUpdate bool - var newRecurrenceRule string - var newDuration string - if len(content.Events) > 0 && len(existing.Events) > 0 { - eventID = existing.Events[0].ID - if content.Events[0].RoomID != nil { - existingRoomID := existing.Events[0].RoomID - if existingRoomID == nil || *content.Events[0].RoomID != *existingRoomID { - needsRoomUpdate = true - newRoomID = content.Events[0].RoomID - } - } - if content.Events[0].RecurrenceRule != "" && content.Events[0].RecurrenceRule != existing.Events[0].RecurrenceRule { - needsScheduleUpdate = true - newRecurrenceRule = content.Events[0].RecurrenceRule - } - if content.Events[0].Duration != "" && content.Events[0].Duration != existing.Events[0].Duration { - needsScheduleUpdate = true - newDuration = content.Events[0].Duration - } - } - - originalStatus := existing.Status - - updateStruct(existing, content) - existing.ID = existingID - - if content.UpdateInstructor { - if err := trans.Model(&ProgramClassEvent{}). - Where("cohort_id = ?", existing.ID). - Update("instructor_id", content.InstructorID).Error; err != nil { - trans.Rollback() - return nil, nil, newUpdateDBError(err, "program class event instructor") - } - } - - if err := trans.Session(&gorm.Session{FullSaveAssociations: false}).Model(&ProgramClassCohort{}).Where("id = ?", existing.ID).Updates(existing).Error; err != nil { - trans.Rollback() - return nil, nil, newUpdateDBError(err, "program classes") - } - - if needsRoomUpdate { - if err := trans.Model(&ProgramClassEvent{}).Where("id = ?", eventID).Update("room_id", newRoomID).Error; err != nil { - trans.Rollback() - return nil, nil, newUpdateDBError(err, "program class event room") - } - existing.Events[0].RoomID = newRoomID - var roomName string - if newRoomID != nil { - var room Room - if err := trans.Select("name").First(&room, *newRoomID).Error; err == nil { - roomName = room.Name - } - } - allChanges = append(allChanges, *NewChangeLogEntry(TableNameCohort, "event_room_changed", nil, &roomName, existing.ID, DerefUint(content.UpdateUserID))) - } - - if needsScheduleUpdate { - if newRecurrenceRule != "" { - if err := trans.Model(&ProgramClassEvent{}).Where("id = ?", eventID).Update("recurrence_rule", newRecurrenceRule).Error; err != nil { - trans.Rollback() - return nil, nil, newUpdateDBError(err, "program class event recurrence rule") - } - } - if newDuration != "" { - if err := trans.Model(&ProgramClassEvent{}).Where("id = ?", eventID).Update("duration", newDuration).Error; err != nil { - trans.Rollback() - return nil, nil, newUpdateDBError(err, "program class event duration") - } - } - oldRule := existing.Events[0].RecurrenceRule - allChanges = append(allChanges, *NewChangeLogEntry(TableNameCohort, "event_rescheduled_series", &oldRule, &newRecurrenceRule, existing.ID, DerefUint(content.UpdateUserID))) - } - - newStatus := existing.Status - if newStatus == Active && originalStatus != Active { - if err := trans.Model(&ProgramClassEnrollment{}). - Where("cohort_id = ? AND enrollment_status = ? AND enrolled_at IS NULL", id, Enrolled). - Updates(map[string]any{ - "enrolled_at": time.Now().UTC(), - "update_user_id": content.UpdateUserID, - }).Error; err != nil { - trans.Rollback() - return nil, nil, newUpdateDBError(err, "backfilling enrolled_at on activation") - } - } - if newStatus != originalStatus && (newStatus == Completed || newStatus == Cancelled) { - completionTime := time.Now().UTC() - if err := db.UpdateClassEventRRuleUntilDate(trans, []int{id}, completionTime); err != nil { - trans.Rollback() - return nil, nil, newUpdateDBError(err, "updating class event rrule until date") - } - - var enrollmentStatus ProgramEnrollmentStatus - if newStatus == Cancelled { - enrollmentStatus = EnrollmentCancelled - } else { - enrollmentStatus = EnrollmentCompleted - } - - if err := trans. - Model(&ProgramClassEnrollment{}). - Where("cohort_id = ? AND enrollment_status = ?", id, Enrolled). - Set("cohort_id", id). - Update("enrollment_status", enrollmentStatus). - Error; err != nil { - trans.Rollback() - return nil, nil, newUpdateDBError(err, "class enrollment statuses") - } - - if newStatus == Completed { - var completedEnrollments []ProgramClassEnrollment - if err := completionPreloads(trans). - Where("cohort_id = ? AND enrollment_status = ?", id, EnrollmentCompleted). - Find(&completedEnrollments).Error; err != nil { - trans.Rollback() - return nil, nil, newNotFoundDBError(err, "fetching completed enrollments") - } - - if len(completedEnrollments) > 0 { - var admin User - if err := trans.First(&admin, "id = ?", DerefUint(content.UpdateUserID)).Error; err != nil { - trans.Rollback() - return nil, nil, newNotFoundDBError(err, "admin user") - } - - completions := make([]ClassCompletion, 0, len(completedEnrollments)) - for _, enrollment := range completedEnrollments { - completions = append(completions, newClassCompletion(enrollment, admin.Email)) - } - - // see the note in GraduateEnrollments -- one certificate per (user, class) - if err := trans.Clauses(clause.OnConflict{DoNothing: true}).Create(&completions).Error; err != nil { - trans.Rollback() - return nil, nil, newCreateDBError(err, "enrollment completions") - } - } - } - } - - if len(allChanges) > 0 { - if err := trans.Create(&allChanges).Error; err != nil { - trans.Rollback() - return nil, nil, newCreateDBError(err, "change_log_entries") - } - } - - if err := trans.Commit().Error; err != nil { - return nil, nil, newDBError(err, "unable to commit the database transaction") - } - - return existing, nil, nil -} - -func (db *Store) GetTotalEnrollmentsByClassID(id int) (int64, error) { - var count int64 - if err := db.Model(&ProgramClassEnrollment{}).Where("cohort_id = ? and enrollment_status = 'Enrolled'", id).Count(&count).Error; err != nil { - return 0, newDBError(err, "program_class_enrollments") - } - return count, nil -} - -func (db *Store) GetHistoricalEnrollmentForDates(classID int, dates []string) (map[string]int64, error) { - results := make(map[string]int64) - - for _, date := range dates { - trimmedDate := strings.TrimSpace(date) - var count int64 - if err := db.Model(&ProgramClassEnrollment{}). - Where("cohort_id = ?", classID). - Where("DATE(enrolled_at) <= ?", trimmedDate). - Where("enrollment_ended_at IS NULL OR DATE(enrollment_ended_at) > ?", trimmedDate). - Count(&count).Error; err != nil { - return nil, newDBError(err, "historical enrollment count for date: "+trimmedDate) - } - results[trimmedDate] = count - } - - return results, nil -} - -func (db *Store) GetProgramClassDetailsByID(id int, args *QueryContext) ([]ProgramClassDetail, error) { - var classDetails []ProgramClassDetail - query := db.WithContext(args.Ctx).Table("program_class_cohorts ps"). - Select(`ps.*, - fac.name as facility_name, - pc.name as class_name, - count(CASE WHEN pse.enrollment_status = 'Enrolled' THEN 1 END) as enrolled, - count(CASE WHEN pse.enrollment_status = 'Completed' THEN 1 END) as completed, - count(CASE WHEN pse.enrollment_status IN (`+completedOutcomeStatusSQLList()+`) THEN 1 END) as historical_enrollments, - count(CASE WHEN pse.enrollment_status = 'Completed' AND pse.enrollment_ended_at IS NOT NULL THEN 1 END) as rate_eligible_completions, - count(CASE WHEN pse.enrollment_status IN (`+completedOutcomeStatusSQLList()+`) AND pse.enrollment_ended_at IS NOT NULL THEN 1 END) as rate_eligible_enrollments - `). - Joins(`join facilities fac on fac.id = ps.facility_id - AND fac.deleted_at IS NULL`). - Joins(`join program_classes pc on pc.id = ps.class_id`). - Joins(`left outer join program_class_enrollments pse on pse.cohort_id = ps.id`). //TODO Enrollment statuses may change here - Where("ps.program_id = ?", id) - - if args.Params.Get("facility_id") != "" { - query = query.Where("ps.facility_id = ?", args.FacilityID) - } else if !args.CanSwitchFacility { - query = query.Where("ps.facility_id = ?", args.FacilityID) - } - - query = query.Group("ps.id,fac.name,pc.name") - if args.Search != "" { - // pc.name, not ps.name -- the screen shows the CLASS's name, so searching the - // cohort's would return nothing for what the user can actually see. - query = query.Where("LOWER(pc.name) LIKE ? OR LOWER(ps.description) LIKE ?", args.SearchQuery(), args.SearchQuery()) - } - if err := query.Count(&args.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "programs") - } - if err := query.Limit(args.PerPage).Offset(args.CalcOffset()).Order(args.OrderClause("ps.created_at desc")).Find(&classDetails).Error; err != nil { - return nil, newGetRecordsDBError(err, "programs") - } - - classIDs := make([]uint, 0, len(classDetails)) - for _, detail := range classDetails { //gathering all for next query - classIDs = append(classIDs, detail.ID) - } - events := []ProgramClassEvent{} - if err := db.Model(&ProgramClassEvent{}). - Preload("RoomRef"). - // See GetClasses: without Instructor the program-detail Classes tab renders "—" - // in its Instructor column, because that column comes from the event, not the cohort. - Preload("Instructor"). - Preload("Overrides"). - Preload("Overrides.RoomRef"). - Where("cohort_id IN (?)", classIDs). - Find(&events).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - eventMap := make(map[uint][]ProgramClassEvent) - for _, event := range events { - eventMap[event.CohortID] = append(eventMap[event.CohortID], event) - } - for i, j := 0, len(classDetails); i < j; i++ { - classDetails[i].Events = eventMap[classDetails[i].ID] - } - - return classDetails, nil -} - -type ProgramClassOutcomes struct { - Month string `json:"month"` - Drops int `json:"drops"` - Completions int `json:"completions"` -} - -func (db *Store) GetProgramClassOutcomes(id int, args *QueryContext) ([]ProgramClassOutcomes, error) { - var outcomes []ProgramClassOutcomes - - facilityID := args.FacilityID - incompleteStatuses := []ProgramEnrollmentStatus{ - EnrollmentIncompleteDropped, - EnrollmentIncompleteFailedToComplete, - EnrollmentIncompleteTransfered, - EnrollmentIncompleteSegregated} - - // Create a set that includes the last 6 months, excluding the current month, of program outcomes - const lastSixMonthsSubquery = `(SELECT TO_CHAR( - DATE_TRUNC('month', NOW()) - INTERVAL '1 month' * gs.i, 'YYYY-MM') AS month - FROM generate_series(1, 6) AS gs(i))` - - enrollmentsSubquery := `(SELECT * - FROM program_class_enrollments - WHERE cohort_id IN ( - SELECT id FROM program_class_cohorts - WHERE program_id = ? AND facility_id = ? - ))` - - query := db.WithContext(args.Ctx). - Table(fmt.Sprintf("(%s) AS months", lastSixMonthsSubquery)). - Select(` - months.month, - COALESCE( - COUNT(CASE WHEN pce.enrollment_status = ? THEN pce.cohort_id END), 0) AS completions, - COALESCE(COUNT(CASE WHEN pce.enrollment_status IN (?) THEN pce.cohort_id END),0) AS drops - `, EnrollmentCompleted, incompleteStatuses). - Joins(fmt.Sprintf(` - LEFT JOIN (%s) AS pce - ON TO_CHAR(DATE_TRUNC('month', pce.updated_at), 'YYYY-MM') = months.month - `, enrollmentsSubquery), id, facilityID). - Group("months.month"). - Order(args.OrderBy) - if err := query.Find(&outcomes).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - return outcomes, nil -} - -func (db *Store) GetProgramClassesHistory(id int, tableName string, args *QueryContext) ([]ProgramClassesHistory, error) { - history := []ProgramClassesHistory{} - if err := db.WithContext(args.Ctx).Order(args.OrderClause("created_at desc")). - Find(&history, "parent_ref_id = ? and table_name = ?", id, tableName). - Error; err != nil { - return nil, newGetRecordsDBError(err, "program_classes_history") - } - return history, nil -} - -func (db *Store) GetClassCreatedAtAndBy(id int, args *QueryContext) (ActivityHistoryResponse, error) { - var classDetails ActivityHistoryResponse - if err := db.WithContext(args.Ctx).Table("program_class_cohorts ps"). - Select("ps.created_at, u.username as admin_username"). - Joins("join users u on u.id = ps.create_user_id"). - Where("ps.id = ?", id). - Scan(&classDetails).Error; err != nil { - return classDetails, newNotFoundDBError(err, "program class cohort") - } - return classDetails, nil -} - -func (db *Store) GetFacilityInstructors(facilityID int) ([]Instructor, error) { - var instructors []Instructor - - if err := db.Table("users u"). - Select("u.id, u.username, u.name_first, u.name_last, u.email"). - Where("u.facility_id = ? AND u.role IN ? AND u.deactivated_at IS NULL", facilityID, []string{"facility_admin", "department_admin"}). - Order("u.name_first, u.name_last"). - Find(&instructors).Error; err != nil { - return nil, newGetRecordsDBError(err, "instructors") - } - - // Prepend "Unassigned" option with ID = 0 - unassigned := Instructor{ - ID: 0, - Username: "unassigned", - NameFirst: "Unassigned", - NameLast: "", - Email: "", - } - - // Create a new slice with Unassigned first, then the real instructors - result := make([]Instructor, 0, len(instructors)+1) - result = append(result, unassigned) - result = append(result, instructors...) - - return result, nil -} - -func (db *Store) GetInstructorNameByID(instructorID uint, facilityID uint, roles ...users.Role) (string, error) { - if len(roles) == 0 { - roles = []users.Role{users.FacilityAdmin, users.DepartmentAdmin} - } - var instructorName string - err := db.Table("users"). - Select("COALESCE(name_first || ' ' || name_last, username)"). - Where("id = ? AND facility_id = ? AND role IN ?", - instructorID, facilityID, roles). - Scan(&instructorName).Error - if err != nil { - return "", err - } - return instructorName, nil -} - -func (db *Store) GetClassesByInstructor(instructorID, facilityID int, startDate, endDate string) ([]InstructorClassData, error) { - var classes []InstructorClassData - - // Handle unassigned classes (instructorID = 0) - var query *gorm.DB - if instructorID == 0 { - query = db.Table("program_class_cohorts pc"). - Select(`pc.id as id, - cl.name as name, - 0 as session_count, - COALESCE(COUNT(DISTINCT CASE WHEN pce.enrollment_status = ? THEN pce.id END), 0) as enrolled_count, - 0 as upcoming_sessions, - 0 as cancelled_sessions`, Enrolled). - Joins("JOIN program_classes cl ON cl.id = pc.class_id"). - Joins("LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id"). - Where("NOT EXISTS (SELECT 1 FROM program_class_events WHERE cohort_id = pc.id AND instructor_id IS NOT NULL) AND pc.facility_id = ?", facilityID). - Where("pc.status != ?", Cancelled). - Group("pc.id, cl.name"). - Order("cl.name") - } else { - query = db.Table("program_class_cohorts pc"). - Select(`pc.id as id, - cl.name as name, - 0 as session_count, - COALESCE(COUNT(DISTINCT CASE WHEN pce.enrollment_status = ? THEN pce.id END), 0) as enrolled_count, - 0 as upcoming_sessions, - 0 as cancelled_sessions`, Enrolled). - Joins("JOIN program_classes cl ON cl.id = pc.class_id"). - Joins("LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id"). - Where("EXISTS (SELECT 1 FROM program_class_events WHERE cohort_id = pc.id AND instructor_id = ?) AND pc.facility_id = ?", instructorID, facilityID). - Where("pc.status != ?", Cancelled). - Group("pc.id, cl.name"). - Order("cl.name") - } - - if err := query.Find(&classes).Error; err != nil { - return nil, newGetRecordsDBError(err, "program classes") - } - - start, end, err := parseDateRange(startDate, endDate) - if err != nil { - return nil, newDBError(err, err.Error()) - } - - for i := range classes { - totalSessions, upcomingSessions, cancelledSessions, sessionDates, err := db.calculateSessionCounts(classes[i].ID, start, end) - if err != nil { - return nil, err - } - - classes[i].SessionCount = totalSessions - classes[i].UpcomingSessions = upcomingSessions - classes[i].CancelledSessions = cancelledSessions - classes[i].SessionDates = sessionDates - - var event struct { - RecurrenceRule string `gorm:"column:recurrence_rule"` - Duration string `gorm:"column:duration"` - RoomName string `gorm:"column:room_name"` - } - db.Table("program_class_events pce"). - Select("pce.recurrence_rule, pce.duration, COALESCE(r.name, '') as room_name"). - Joins("LEFT JOIN rooms r ON r.id = pce.room_id"). - Where("pce.cohort_id = ?", classes[i].ID). - Order("pce.created_at ASC"). - Limit(1). - Scan(&event) - - if event.RecurrenceRule != "" { - rule, rErr := rrule.StrToRRule(event.RecurrenceRule) - if rErr == nil { - classes[i].StartTime = rule.GetDTStart().Format("15:04") - } - } - classes[i].Duration = event.Duration - classes[i].Room = event.RoomName - } - - return classes, nil -} - -func (db *Store) calculateSessionCounts(classID int, startDate, endDate time.Time) (int, int, int, []string, error) { - - var events []struct { - ProgramClassEvent - Overrides []ProgramClassEventOverride `gorm:"foreignKey:EventID;references:ID"` - } - - if err := db.Preload("Overrides").Where("cohort_id = ?", classID).Find(&events).Error; err != nil { - return 0, 0, 0, nil, newGetRecordsDBError(err, "class events") - } - - var totalSessions, upcomingSessions, cancelledSessions int - var sessionDates []string - - for _, event := range events { - rule, err := rrule.StrToRRule(event.RecurrenceRule) - if err != nil { - continue // Skip invalid rules - } - - occurrences := rule.Between(startDate, endDate, true) - totalSessions += len(occurrences) - - cancelledDates := make(map[string]bool) - cancelledInDateRange := 0 - for _, override := range event.Overrides { - if override.IsCancelled { - overrideRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - continue // Skip invalid override rules - } - - overrideOccurrences := overrideRule.Between(startDate, endDate, true) - cancelledInDateRange += len(overrideOccurrences) - - for _, occ := range overrideOccurrences { - cancelledDates[occ.Format("2006-01-02")] = true - } - } - } - - actualUpcoming := 0 - for _, occurrence := range occurrences { - dateStr := occurrence.Format("2006-01-02") - if !cancelledDates[dateStr] { - actualUpcoming++ - sessionDates = append(sessionDates, dateStr) - } - } - upcomingSessions += actualUpcoming - - cancelledSessions += cancelledInDateRange - } - - return totalSessions, upcomingSessions, cancelledSessions, sessionDates, nil -} - -func (db *Store) BulkCancelSessions(req *BulkCancelSessionsRequest, facilityID int, claims BulkCancelClaims) (*BulkCancelSessionsResponse, error) { - ctx := context.Background() - - if err := Validate().Struct(req); err != nil { - return nil, newDBError(err, "bulk cancel sessions validation error") - } - - tx := db.WithContext(ctx).Begin() - if tx.Error != nil { - return nil, newUpdateDBError(tx.Error, "begin transaction") - } - defer func() { - if r := recover(); r != nil { - if err := tx.Rollback().Error; err != nil { - log.WithError(err).Error("Error rolling back transaction in BulkCancelSessions after panic") - } - log.WithField("stack", string(debug.Stack())).Error("Panic in BulkCancelSessions") - panic(r) - } - }() - - start, end, err := parseDateRange(req.StartDate, req.EndDate) - if err != nil { - tx.Rollback() - return nil, newDBError(err, err.Error()) - } - - var baseEvents []ProgramClassEvent - var query *gorm.DB - if req.InstructorID == 0 { - query = tx.Table("program_class_events pce"). - Select("pce.*"). - Joins("INNER JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Where("pce.instructor_id IS NULL AND pc.facility_id = ?", facilityID) - } else { - query = tx.Table("program_class_events pce"). - Select("pce.*"). - Joins("INNER JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Where("pce.instructor_id = ? AND pc.facility_id = ?", req.InstructorID, facilityID) - } - - if err := query.Find(&baseEvents).Error; err != nil { - tx.Rollback() - return nil, newGetRecordsDBError(err, "class events") - } - - var eventInstances []struct { - Event ProgramClassEvent - Occurrence time.Time - } - for _, baseEvent := range baseEvents { - rule, err := baseEvent.GetRRule() - if err != nil { - continue - } - occurrences := rule.Between(start, end, true) - firstOccurrence := rule.After(time.Time{}, false) - canonicalHour, canonicalMinute := getCanonicalHourAndMinute([]time.Time{firstOccurrence}, claims.GetTimezone()) - userLocation, _ := time.LoadLocation(claims.GetTimezone()) - for _, occurrence := range occurrences { - consistentOccurrence := time.Date( - occurrence.Year(), - occurrence.Month(), - occurrence.Day(), - canonicalHour, - canonicalMinute, - 0, - 0, - userLocation, - ).UTC() - eventInstances = append(eventInstances, struct { - Event ProgramClassEvent - Occurrence time.Time - }{ - Event: baseEvent, - Occurrence: consistentOccurrence, - }) - } - } - - if len(eventInstances) == 0 { - if err := tx.Rollback().Error; err != nil { - log.WithError(err).Error("Error rolling back transaction in BulkCancelSessions") - } - return &BulkCancelSessionsResponse{ - Success: false, - SessionCount: 0, - ClassCount: 0, - StudentCount: 0, - Classes: []AffectedClass{}, - }, nil - } - - // Get unique classes affected and count cancelled sessions - classMap := make(map[int]*AffectedClass) - for _, instance := range eventInstances { - if _, exists := classMap[int(instance.Event.CohortID)]; !exists { - classMap[int(instance.Event.CohortID)] = &AffectedClass{ - ClassID: int(instance.Event.CohortID), - ClassName: "", - UpcomingSessions: 0, - CancelledSessions: 0, - StudentCount: 0, - } - } - classMap[int(instance.Event.CohortID)].CancelledSessions++ - } - - classIDs := make([]uint, 0, len(classMap)) - for classID := range classMap { - classIDs = append(classIDs, uint(classID)) - } - - countMap, err := db.GetActiveEnrollmentCountsForClasses(&QueryContext{Ctx: ctx}, classIDs) - if err != nil { - tx.Rollback() - return nil, err - } - - type classInfo struct { - ID int `gorm:"column:id"` - Name string `gorm:"column:name"` - } - var classInfos []classInfo - if err := tx.Table("program_class_cohorts c"). - Joins("JOIN program_classes pc ON pc.id = c.class_id"). - Select("c.id AS id, pc.name AS name"). - Where("c.id IN ?", classIDs). - Scan(&classInfos).Error; err != nil { - tx.Rollback() - return nil, newGetRecordsDBError(err, "class names") - } - - nameMap := make(map[int]string) - for _, info := range classInfos { - nameMap[info.ID] = info.Name - } - - for classID := range classMap { - classMap[classID].StudentCount = countMap[uint(classID)] - classMap[classID].ClassName = nameMap[classID] - } - - existingOverridesMap := make(map[uint]map[string]bool) - - eventIDs := make([]uint, 0, len(eventInstances)) - rruleMap := make(map[string]bool) - for _, instance := range eventInstances { - eventIDs = append(eventIDs, instance.Event.ID) - overrideRrule := fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=DAILY;COUNT=1", - instance.Occurrence.Format("20060102T150405Z")) - rruleMap[overrideRrule] = true - } - - if len(eventIDs) > 0 { - var existingOverrides []ProgramClassEventOverride - rruleList := make([]string, 0, len(rruleMap)) - for rrule := range rruleMap { - rruleList = append(rruleList, rrule) - } - - if err := tx.Table("program_class_event_overrides"). - Where("event_id IN ? AND override_rrule IN ? AND is_cancelled = ? AND deleted_at IS NULL", - eventIDs, rruleList, true). - Find(&existingOverrides).Error; err != nil { - tx.Rollback() - return nil, newGetRecordsDBError(err, "existing overrides") - } - - for _, override := range existingOverrides { - if existingOverridesMap[override.EventID] == nil { - existingOverridesMap[override.EventID] = make(map[string]bool) - } - existingOverridesMap[override.EventID][override.OverrideRrule] = true - } - } - var newCancellations []struct { - Event ProgramClassEvent - Occurrence time.Time - } - var alreadyCancelled []struct { - Event ProgramClassEvent - Occurrence time.Time - } - - for _, instance := range eventInstances { - overrideRrule := fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=DAILY;COUNT=1", - instance.Occurrence.Format("20060102T150405Z")) - - // Check if this override already exists - if eventOverrides, exists := existingOverridesMap[instance.Event.ID]; exists { - if eventOverrides[overrideRrule] { - // Already cancelled - alreadyCancelled = append(alreadyCancelled, instance) - continue - } - } - newCancellations = append(newCancellations, instance) - } - - classMap = make(map[int]*AffectedClass) - for _, instance := range newCancellations { - if _, exists := classMap[int(instance.Event.CohortID)]; !exists { - classMap[int(instance.Event.CohortID)] = &AffectedClass{ - ClassID: int(instance.Event.CohortID), - ClassName: "", - UpcomingSessions: 0, - CancelledSessions: 0, - StudentCount: 0, - } - } - classMap[int(instance.Event.CohortID)].CancelledSessions++ - } - - for classID := range classMap { - classMap[classID].StudentCount = countMap[uint(classID)] - classMap[classID].ClassName = nameMap[classID] - } - - var overrides []ProgramClassEventOverride - var auditEntries []ChangeLogEntry - for _, instance := range newCancellations { - overrideRrule := fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=DAILY;COUNT=1", - instance.Occurrence.Format("20060102T150405Z")) - - overrides = append(overrides, ProgramClassEventOverride{ - EventID: instance.Event.ID, - OverrideRrule: overrideRrule, - Duration: instance.Event.Duration, - RoomID: instance.Event.RoomID, - IsCancelled: true, - Reason: StringPtr(req.Reason), - }) - - oldStatus := "Scheduled" - newStatus := "Cancelled" - - auditEntry := NewChangeLogEntry( - TableNameCohort, - "status", - &oldStatus, - &newStatus, - instance.Event.CohortID, - claims.GetUserID(), - ) - auditEntries = append(auditEntries, *auditEntry) - } - - // Bulk insert overrides with ON CONFLICT DO NOTHING for idempotency (unique partial index on event_id, override_rrule WHERE deleted_at IS NULL). - if len(overrides) > 0 { - const overrideBatchSize = 100 - if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "event_id"}, {Name: "override_rrule"}}, - TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "deleted_at IS NULL"}}}, - DoNothing: true, - }).CreateInBatches(overrides, overrideBatchSize).Error; err != nil { - tx.Rollback() - return nil, newCreateDBError(err, "event override") - } - } - - if len(auditEntries) > 0 { - if err := tx.Create(&auditEntries).Error; err != nil { - tx.Rollback() - return nil, newCreateDBError(err, "change log entries") - } - } - - if err := tx.Commit().Error; err != nil { - return nil, newUpdateDBError(err, "commit transaction") - } - - var affectedClasses []AffectedClass - for _, class := range classMap { - affectedClasses = append(affectedClasses, *class) - } - - totalStudents := 0 - for _, class := range affectedClasses { - totalStudents += class.StudentCount - } - - alreadyCancelledCount := len(alreadyCancelled) - var message string - if alreadyCancelledCount > 0 { - if len(newCancellations) == 0 { - message = fmt.Sprintf("All %d sessions in the selected range were already cancelled.", alreadyCancelledCount) - } else { - message = fmt.Sprintf("Successfully cancelled %d sessions. %d sessions were already cancelled and were skipped.", - len(newCancellations), alreadyCancelledCount) - } - } - - response := &BulkCancelSessionsResponse{ - Success: true, - SessionCount: len(newCancellations), - ClassCount: len(affectedClasses), - StudentCount: totalStudents, - AlreadyCancelledCount: alreadyCancelledCount, - Message: message, - Classes: affectedClasses, - } - - return response, nil -} - -func (db *Store) DeleteClass(id int) error { - return db.Transaction(func(tx *gorm.DB) error { - // Read the parent BEFORE the delete: afterwards GORM's soft-delete scope hides - // the cohort row and there is nothing left to resolve class_id from. - var classID uint - if err := tx.Model(&ProgramClassCohort{}). - Select("class_id"). - Where("id = ?", id). - Scan(&classID).Error; err != nil { - return newGetRecordsDBError(err, "program class parent") - } - - if err := tx.Where("cohort_id = ?", id).Delete(&ProgramClassEvent{}).Error; err != nil { - return newDeleteDBError(err, "program_class_events") - } - if err := tx.Exec(`DELETE FROM change_log_entries WHERE table_name = ? AND parent_ref_id = ?`, TableNameCohort, id).Error; err != nil { - return newDeleteDBError(err, "change_log_entries") - } - if err := tx.Exec(`DELETE FROM program_classes_history WHERE table_name = ? AND parent_ref_id = ?`, TableNameCohort, id).Error; err != nil { - return newDeleteDBError(err, "program_classes_history") - } - if err := tx.Delete(&ProgramClassCohort{}, "id = ?", id).Error; err != nil { - return newDeleteDBError(err, "program class") - } - return deleteClassIfLastCohort(tx, classID) - }) -} - -/* -deleteClassIfLastCohort removes the parent CLASS once its final cohort is gone, so -deleting the only class a user can see does not leave an invisible class-tier row behind. -That orphan is not cosmetic: it still matches the create form's class dropdown and it -still counts toward the duplicate-name guard, so a facility would be offered a "class" -with no runs and be unable to recreate one under the same name. - -Three things make this safe rather than destructive: - - - the CLASS is SOFT-deleted, exactly as the cohort is. Its row stays put, so none of - the FKs pointing at program_classes fire -- and two of them would bite on a hard - delete: class_completions.class_id is ON DELETE SET NULL (it would blank the - certificate's class link) while program_class_cohorts and program_class_enrollments - have no ON DELETE at all, so a hard delete would be RESTRICTed by the soft-deleted - rows still referencing it. - - the remaining-cohort count runs through GORM's default scope, so soft-deleted - siblings correctly do NOT keep the class alive. - - a class still referenced by a live certificate is LEFT ALONE. The cohort delete - guard only checks completions for the cohort being deleted; a certificate earned in - a sibling cohort that was itself deleted earlier still points at the class, and - hiding it would orphan that certificate's class link. -*/ -func deleteClassIfLastCohort(tx *gorm.DB, classID uint) error { - if classID == 0 { - return nil - } - var remainingCohorts int64 - if err := tx.Model(&ProgramClassCohort{}). - Where("class_id = ?", classID). - Count(&remainingCohorts).Error; err != nil { - return newGetRecordsDBError(err, "remaining cohorts") - } - if remainingCohorts > 0 { - return nil - } - - var completions int64 - if err := tx.Model(&ClassCompletion{}). - Where("class_id = ?", classID). - Count(&completions).Error; err != nil { - return newGetRecordsDBError(err, "class completions") - } - if completions > 0 { - return nil - } - - if err := tx.Exec(`DELETE FROM change_log_entries WHERE table_name = ? AND parent_ref_id = ?`, TableNameClass, classID).Error; err != nil { - return newDeleteDBError(err, "change_log_entries") - } - if err := tx.Exec(`DELETE FROM program_classes_history WHERE table_name = ? AND parent_ref_id = ?`, TableNameClass, classID).Error; err != nil { - return newDeleteDBError(err, "program_classes_history") - } - if err := tx.Delete(&ProgramClass{}, "id = ?", classID).Error; err != nil { - return newDeleteDBError(err, "program class tier") - } - return nil -} - -// GetClassesForProgram returns the CLASS tier under a program, each with its -// cohorts rolled up. Facility scoping matches the cohort query: an explicit -// facility_id param, or the caller's own facility when they cannot switch. -// -// Scope on args.FacilityID, NOT on the presence of the raw param. getQueryContext -// parses facility_id and leaves FacilityID at 0 when the value is unparseable, so -// keying off Params.Get("facility_id") != "" made "?facility_id=abc" filter on -// facility_id = 0 and silently return an empty list. The `!CanSwitchFacility` arm -// stays so a non-switching caller still fails closed if their claim carries no -// facility. -func (db *Store) GetClassesForProgram(programID int, args *QueryContext) ([]ProgramClass, error) { - classes := make([]ProgramClass, 0, args.PerPage) - - query := db.WithContext(args.Ctx).Table("program_classes pc"). - Where("pc.deleted_at IS NULL AND pc.program_id = ?", programID) - - if args.FacilityID != 0 || !args.CanSwitchFacility { - query = query.Where("pc.facility_id = ?", args.FacilityID) - } - if args.Search != "" { - query = query.Where("LOWER(pc.name) LIKE ? OR LOWER(pc.description) LIKE ?", - args.SearchQuery(), args.SearchQuery()) - } - - if err := db.WithContext(args.Ctx).Table("(?) AS counted", query). - Count(&args.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_classes") - } - if err := query.Order("pc.name"). - Limit(args.PerPage).Offset(args.CalcOffset()). - Scan(&classes).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_classes") - } - // Rollups are computed fields (gorm:"-"), so GORM will not scan them from aliased - // SELECT columns -- it drops ignored fields from the schema entirely. Populating them - // through the shared helper keeps list and detail numerically identical by - // construction rather than by two queries that happen to agree today. - ids := make([]uint, 0, len(classes)) - for i := range classes { - ids = append(ids, classes[i].ID) - } - rollups, err := db.classRollupsFor(args, ids) - if err != nil { - return nil, err - } - for i := range classes { - if r, ok := rollups[classes[i].ID]; ok { - classes[i].CohortCount = r.CohortCount - classes[i].ActiveCohorts = r.ActiveCohorts - classes[i].ScheduledCohorts = r.ScheduledCohorts - classes[i].CompletedCohorts = r.CompletedCohorts - classes[i].Capacity = r.Capacity - classes[i].Enrolled = r.Enrolled - classes[i].Completed = r.Completed - } - } - return classes, nil -} - -// GetClassByID returns one class with its rollups, its cohorts, and its credit -// types. An empty CreditTypes slice means "inherit from the program" -- callers -// must resolve with ProgramClass.CreditTypesOrInherited, never read it raw. -func (db *Store) GetClassByID(id int, args *QueryContext) (*ProgramClass, error) { - var class ProgramClass - if err := db.WithContext(args.Ctx). - Preload("Cohorts", "deleted_at IS NULL"). - Preload("CreditTypes"). - First(&class, "id = ?", id).Error; err != nil { - return nil, newNotFoundDBError(err, "program_classes") - } - - rollups, err := db.classRollupsFor(args, []uint{class.ID}) - if err != nil { - return nil, err - } - if r, ok := rollups[class.ID]; ok { - class.CohortCount = r.CohortCount - class.ActiveCohorts = r.ActiveCohorts - class.ScheduledCohorts = r.ScheduledCohorts - class.CompletedCohorts = r.CompletedCohorts - class.Capacity = r.Capacity - class.Enrolled = r.Enrolled - class.Completed = r.Completed - } - if err := db.resolveInheritedCreditTypes(args, &class); err != nil { - return nil, err - } - return &class, nil -} - -// resolveInheritedCreditTypes fills in a class's credit types from its program when the -// class defines no override of its own, so callers do not have to know the -// empty-means-inherit rule to render a class correctly. -func (db *Store) resolveInheritedCreditTypes(args *QueryContext, class *ProgramClass) error { - if len(class.CreditTypes) > 0 { - return nil - } - var programTypes []ProgramCreditType - if err := db.WithContext(args.Ctx). - Where("program_id = ?", class.ProgramID). - Find(&programTypes).Error; err != nil { - return newGetRecordsDBError(err, "program_credit_types") - } - inherited := make([]ProgramClassCreditType, 0, len(programTypes)) - for _, pt := range programTypes { - inherited = append(inherited, ProgramClassCreditType{ - ClassID: class.ID, CreditType: pt.CreditType}) - } - class.CreditTypes = inherited - return nil -} - -type classRollup struct { - ClassID uint - CohortCount int64 - ActiveCohorts int64 - ScheduledCohorts int64 - CompletedCohorts int64 - Capacity int64 - Enrolled int64 - Completed int64 -} - -// classRollupsFor aggregates cohorts up to their class, for any number of classes. -// -// One definition, used by both the list and the detail read. Two divergent definitions -// of "enrolled" is how reporting bugs start, and this restructure exists precisely to -// make these numbers meaningful. -// -// COUNT(DISTINCT user_id) is deliberate: after a merge one resident may hold enrollments -// in two sibling cohorts of the same class, and they are still one person in a class -// headcount. Capacity, by contrast, is a per-cohort quantity and is summed. -func (db *Store) classRollupsFor(args *QueryContext, classIDs []uint) (map[uint]classRollup, error) { - out := make(map[uint]classRollup, len(classIDs)) - if len(classIDs) == 0 { - return out, nil - } - var rows []classRollup - err := db.WithContext(args.Ctx).Raw(` -SELECT pc.id AS class_id, - (SELECT COUNT(*) FROM program_class_cohorts c - WHERE c.class_id = pc.id AND c.deleted_at IS NULL) AS cohort_count, - -- Per-status cohort counts. The program page used to group cohorts by status; the - -- class tier keeps that information on the class row rather than losing it. - (SELECT COUNT(*) FROM program_class_cohorts c - WHERE c.class_id = pc.id AND c.deleted_at IS NULL AND c.status = 'Active') AS active_cohorts, - (SELECT COUNT(*) FROM program_class_cohorts c - WHERE c.class_id = pc.id AND c.deleted_at IS NULL AND c.status = 'Scheduled') AS scheduled_cohorts, - (SELECT COUNT(*) FROM program_class_cohorts c - WHERE c.class_id = pc.id AND c.deleted_at IS NULL AND c.status = 'Completed') AS completed_cohorts, - (SELECT COALESCE(SUM(c.capacity), 0) FROM program_class_cohorts c - WHERE c.class_id = pc.id AND c.deleted_at IS NULL) AS capacity, - (SELECT COUNT(DISTINCT e.user_id) FROM program_class_enrollments e - WHERE e.class_id = pc.id AND e.deleted_at IS NULL - AND e.enrollment_status = 'Enrolled') AS enrolled, - (SELECT COUNT(DISTINCT cc.user_id) FROM class_completions cc - WHERE cc.class_id = pc.id AND cc.deleted_at IS NULL) AS completed -FROM program_classes pc -WHERE pc.id IN ?`, classIDs).Scan(&rows).Error - if err != nil { - return nil, newGetRecordsDBError(err, "program_classes") - } - for _, r := range rows { - out[r.ClassID] = r - } - return out, nil -} - -// GetClassesForFacility lists the class tier at the caller's facility. -func (db *Store) GetClassesForFacility(args *QueryContext) ([]ProgramClass, error) { - classes := make([]ProgramClass, 0, args.PerPage) - tx := db.WithContext(args.Ctx).Model(&ProgramClass{}). - Where("facility_id = ? AND deleted_at IS NULL", args.FacilityID) - if args.Search != "" { - tx = tx.Where("LOWER(name) LIKE ?", args.SearchQuery()) - } - if err := tx.Count(&args.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_classes") - } - if err := tx.Order("name").Limit(args.PerPage).Offset(args.CalcOffset()). - Find(&classes).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_classes") - } - return classes, nil -} - -// CreateClass creates a class and, optionally, its class-level credit types. -func (db *Store) CreateClass(class *ProgramClass, creditTypes []CreditType) error { - return db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(class).Error; err != nil { - return newCreateDBError(err, "program_classes") - } - return replaceClassCreditTypes(tx, class.ID, creditTypes) - }) -} - -// UpdateClass updates class-level fields. It deliberately cannot touch status, -// capacity or dates -- those live on the cohort. -func (db *Store) UpdateClass(id int, updates map[string]any, creditTypes []CreditType) (*ProgramClass, error) { - var class ProgramClass - err := db.Transaction(func(tx *gorm.DB) error { - if err := tx.First(&class, "id = ?", id).Error; err != nil { - return newNotFoundDBError(err, "program_classes") - } - if len(updates) > 0 { - if err := tx.Model(&class).Updates(updates).Error; err != nil { - return newUpdateDBError(err, "program_classes") - } - } - if creditTypes != nil { - return replaceClassCreditTypes(tx, class.ID, creditTypes) - } - return nil - }) - if err != nil { - return nil, err - } - return &class, nil -} - -// replaceClassCreditTypes sets a class's credit-type override wholesale. -// -// Passing an EMPTY slice is meaningful and is not the same as passing nil: it clears -// the override, which returns the class to inheriting its program's credit types. -func replaceClassCreditTypes(tx *gorm.DB, classID uint, creditTypes []CreditType) error { - if err := tx.Where("class_id = ?", classID). - Delete(&ProgramClassCreditType{}).Error; err != nil { - return newDeleteDBError(err, "program_class_credit_types") - } - if len(creditTypes) == 0 { - return nil - } - rows := make([]ProgramClassCreditType, 0, len(creditTypes)) - for _, ct := range creditTypes { - rows = append(rows, ProgramClassCreditType{ClassID: classID, CreditType: ct}) - } - if err := tx.Create(&rows).Error; err != nil { - return newCreateDBError(err, "program_class_credit_types") - } - return nil -} - -// Class activation (jobs.go) - -// SystemBatchUserID is the id of the system_batch user, which the scheduled -// jobs make their changes as. -func (db *Store) SystemBatchUserID(ctx context.Context) (uint, error) { - var user User - if err := db.WithContext(ctx). - Where("username = ?", "system_batch"). - First(&user).Error; err != nil { - return 0, fmt.Errorf("system_batch user not found: %w", err) - } - return user.ID, nil -} - -// ScheduledCohortsDue is the ids of the Scheduled, unarchived classes whose -// start date has come in their facility's time zone. -func (db *Store) ScheduledCohortsDue(ctx context.Context) ([]int, error) { - var cohortIDs []int - if err := db.WithContext(ctx). - Model(&ProgramClassCohort{}). - Joins("JOIN facilities f ON f.id = program_class_cohorts.facility_id"). - Where("program_class_cohorts.status = ?", Scheduled). - Where("program_class_cohorts.archived_at IS NULL"). - Where("program_class_cohorts.start_dt <= (now() AT TIME ZONE f.timezone)::date"). - Pluck("program_class_cohorts.id", &cohortIDs).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_cohorts") - } - return cohortIDs, nil -} - -// ActivateCohorts makes the classes Active and gives their Enrolled -// enrollments that have no enrolled date enrolledAt, in one transaction. ctx -// carries the user the changes are recorded as, who is also batchUserID. -func (db *Store) ActivateCohorts(ctx context.Context, cohortIDs []int, batchUserID uint, enrolledAt time.Time) error { - if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx. - Model(&ProgramClassCohort{}). - Where("id IN ?", cohortIDs). - Updates(map[string]any{"status": Active}).Error; err != nil { - return err - } - return tx. - Model(&ProgramClassEnrollment{}). - Where("cohort_id IN ?", cohortIDs). - Where("enrollment_status = ?", Enrolled). - Where("enrolled_at IS NULL"). - Updates(map[string]any{ - "enrolled_at": enrolledAt, - "update_user_id": batchUserID, - }).Error - }); err != nil { - return newUpdateDBError(err, "program_class_cohorts") - } - return nil -} diff --git a/backend/src/modules/programs/program_classes_test.go b/backend/src/modules/programs/program_classes_test.go deleted file mode 100644 index 83ad71ca3..000000000 --- a/backend/src/modules/programs/program_classes_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package programs - -import ( - "strings" - "testing" -) - -// CompletedOutcomeStatuses is derived from IsTerminalEnrollment at package init, -// but AllEnrollmentStatuses it reads from is hand-maintained. Adding a status to -// the ProgramEnrollmentStatus const block without listing it there would silently -// drop that status from every completion-rate denominator, so pin both ends. -func TestCompletedOutcomeStatuses(t *testing.T) { - for _, s := range AllEnrollmentStatuses { - if !IsTerminalEnrollment(s) { - continue - } - found := false - for _, got := range CompletedOutcomeStatuses { - if got == s { - found = true - break - } - } - // Cancelled is terminal but never had a completion opportunity. - want := s != EnrollmentCancelled - if found != want { - t.Errorf("CompletedOutcomeStatuses contains %q = %v, want %v", s, found, want) - } - } - - for _, s := range CompletedOutcomeStatuses { - if s == Enrolled || s == EnrollmentCancelled { - t.Errorf("CompletedOutcomeStatuses must not contain %q", s) - } - } -} - -// Every "Incomplete:" constant must be listed in AllEnrollmentStatuses, otherwise -// the derived sets above silently omit it. -func TestAllEnrollmentStatusesCoversIncompletePrefix(t *testing.T) { - declared := []ProgramEnrollmentStatus{ - EnrollmentIncompleteWithdrawn, - EnrollmentIncompleteDropped, - EnrollmentIncompleteFailedToComplete, - EnrollmentIncompleteTransfered, - EnrollmentIncompleteSegregated, - } - for _, s := range declared { - if !strings.HasPrefix(string(s), "Incomplete:") { - t.Fatalf("%q is not an Incomplete: status; fix this test's list", s) - } - found := false - for _, got := range AllEnrollmentStatuses { - if got == s { - found = true - break - } - } - if !found { - t.Errorf("AllEnrollmentStatuses is missing %q", s) - } - } -} diff --git a/backend/src/modules/programs/programs_handler.go b/backend/src/modules/programs/programs_handler.go deleted file mode 100644 index 921fd38c7..000000000 --- a/backend/src/modules/programs/programs_handler.go +++ /dev/null @@ -1,423 +0,0 @@ -package programs - -import ( - "encoding/csv" - "encoding/json" - "errors" - "fmt" - "net/http" - "slices" - "strconv" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/features" - "github.com/UnlockedLabs/unlocked-contracts/users" -) - -/* -* @Query Params: -* ?page=: page -* ?perPage=: perPage -* ?sort=: sort -* ?search=: search -* ?searchFields=: searchFields - */ -func (srv *server) handleIndexPrograms(w http.ResponseWriter, r *http.Request, log sLog) error { - args := getQueryContext(r) - programs, err := srv.store.GetPrograms(&args) - if err != nil { - log.add("search_query", args.Search) - return newDatabaseServiceError(err) - } - return writePaginatedResponse(w, http.StatusOK, programs, args.IntoMeta()) -} - -func (srv *server) handleIndexProgramsFacilitiesStats(w http.ResponseWriter, r *http.Request, log sLog) error { - args := getQueryContext(r) - adminRole := claimsFrom(r).Role - timeFilter, err := strconv.Atoi(r.URL.Query().Get("days")) - if err != nil { - timeFilter = -1 - } - var programs ProgramsFacilitiesStats - if adminRole == users.FacilityAdmin { - programs, err = srv.store.GetProgramsFacilityStats(&args, timeFilter) - } else { - programs, err = srv.store.GetProgramsFacilitiesStats(&args, timeFilter) - } - - if err != nil { - return newDatabaseServiceError(err) - } - - return writeJsonResponse(w, http.StatusOK, programs) -} - -func (srv *server) handleIndexProgramsOverviewTable(w http.ResponseWriter, r *http.Request, log sLog) error { - args := getQueryContext(r) - claims := claimsFrom(r) - adminRole := claims.Role - timeFilter, err := strconv.Atoi(r.URL.Query().Get("days")) - if err != nil { - timeFilter = -1 - } - includeArchived := r.URL.Query().Get("include_archived") == "true" - filters := make(map[string]string, 11) - for key, values := range r.URL.Query() { - if strings.HasPrefix(key, "filter_") && len(values) > 0 { - filters[strings.TrimPrefix(key, "filter_")] = values[0] - } - } - programs, err := srv.store.GetProgramsOverviewTable(&args, timeFilter, includeArchived, adminRole, filters) - if err != nil { - return newDatabaseServiceError(err) - } - for i := range programs { - programs[i].Source = "unlocked" - } - if claims.HasFeature(features.ProviderPlatforms) { - if canvasPrograms, canvasErr := srv.getCanvasProviderPrograms(args.FacilityID, adminRole); canvasErr != nil { - log.errorf("failed to fetch canvas provider programs: %v", canvasErr) - } else { - canvasPrograms = filterCanvasPrograms(canvasPrograms, &args, filters) - dbTotal := args.Total - args.Total += int64(len(canvasPrograms)) - programs = append(programs, canvasPageSlice(canvasPrograms, dbTotal, &args)...) - } - } - return writePaginatedResponse(w, http.StatusOK, programs, args.IntoMeta()) -} - -func (srv *server) handleShowProgram(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - if uint(id) >= CanvasProgramIDOffset { - return srv.handleShowCanvasProgram(w, r, log, uint(id)) - } - - claims := claimsFrom(r) - facilityID := claims.FacilityID - args := getQueryContext(r) - if args.Params.Get("facility_id") != "" { - facilityID = args.FacilityID - } else if claims.CanSwitchFacility() { - facilityID = 0 - } - - program, err := srv.store.GetProgramByID(id) - if err != nil { - log.add("program_id", id) - return newDatabaseServiceError(err) - } - - overview, err := srv.store.FetchEnrollmentMetrics(id, facilityID) - if err != nil { - log.add("program_id", id) - return newDatabaseServiceError(err) - } - - activeClassFacilityIDs, err := srv.store.GetActiveClassFacilityIDs(r.Context(), id) - if err != nil { - log.add("program_id", id) - return newDatabaseServiceError(err) - } - overview.Program = *program - overview.ActiveClassFacilityIDs = activeClassFacilityIDs - return writeJsonResponse(w, http.StatusOK, overview) -} - -type ProgramForm struct { - Name string `json:"name"` - Description string `json:"description"` - FundingType FundingType `json:"funding_type"` - CreditTypes []ProgramCreditType `json:"credit_types"` - IsActive bool `json:"is_active"` - ProgramTypes []ProgramType `json:"program_types"` - Facilities []int `json:"facilities"` - HasProgramCompletion *bool `json:"has_program_completion"` -} - -func (srv *server) handleCreateProgram(w http.ResponseWriter, r *http.Request, log sLog) error { - claims := claimsFrom(r) - var program ProgramForm - err := json.NewDecoder(r.Body).Decode(&program) - if err != nil { - return newJSONReqBodyServiceError(err) - } - if len(program.Facilities) == 0 { - program.Facilities = []int{int(claims.FacilityID)} - } - - newProg := Program{ - Name: program.Name, - Description: program.Description, - FundingType: program.FundingType, - IsActive: program.IsActive, - ProgramTypes: program.ProgramTypes, - ProgramCreditTypes: program.CreditTypes, - HasProgramCompletion: program.HasProgramCompletion != nil && *program.HasProgramCompletion, - } - - err = srv.WithUserContext(r).CreateProgram(&newProg) - if err != nil { - return newDatabaseServiceError(err) - } - log.add("program_id", newProg.ID) - facilityPrograms := IterMap(func(id int) FacilitiesPrograms { - return FacilitiesPrograms{ - FacilityID: uint(id), - ProgramID: newProg.ID, - } - }, program.Facilities) - if err := srv.WithUserContext(r).Model(&FacilitiesPrograms{}).Create(&facilityPrograms).Error; err != nil { - log.info("Error creating facility program: " + err.Error()) - } - return writeJsonResponse(w, http.StatusCreated, newProg) -} - -func (srv *server) handleUpdateProgram(w http.ResponseWriter, r *http.Request, log sLog) error { - claims := claimsFrom(r) - var programForm ProgramForm - err := json.NewDecoder(r.Body).Decode(&programForm) - if err != nil { - return newJSONReqBodyServiceError(err) - } - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - programID := uint(id) - theProg := Program{ - DatabaseFields: DatabaseFields{ - ID: programID, - UpdateUserID: UintPtr(claims.UserID), - }, - Name: programForm.Name, - Description: programForm.Description, - FundingType: programForm.FundingType, - IsActive: programForm.IsActive, - ProgramTypes: programForm.ProgramTypes, - ProgramCreditTypes: programForm.CreditTypes, - } - - // HasProgramCompletion is passed as the raw *bool rather than resolved here: the - // column IS in UpdateProgram's Select list, so it is always written, and an omitted - // field would otherwise decode to false and silently clear the flag. Resolving it - // against a row this handler read separately would reintroduce a lost update, so - // UpdateProgram resolves it against the row it has locked. - updated, updateErr := srv.WithUserContext(r).UpdateProgram(&theProg, programForm.Facilities, programForm.HasProgramCompletion) - if updateErr != nil { - return newDatabaseServiceError(updateErr) - } - return writeJsonResponse(w, http.StatusOK, updated) -} - -func (srv *server) handleUpdateProgramStatus(w http.ResponseWriter, r *http.Request, log sLog) error { - programUpdate := make(map[string]any) - if err := json.NewDecoder(r.Body).Decode(&programUpdate); err != nil { - return newJSONReqBodyServiceError(err) - } - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - log.add("program_id", id) - claims := claimsFrom(r) - programUpdate["update_user_id"] = claims.UserID - facilities, updated, err := srv.WithUserContext(r).UpdateProgramStatus(programUpdate, uint(id)) - if err != nil { - return newDatabaseServiceError(err) - } - payload := struct { - Updated bool `json:"updated"` - Facilities []string `json:"facilities"` - Message string `json:"message"` - }{ - Updated: updated, - Facilities: facilities, - } - - if updated { - payload.Message = "Program status updated successfully" - } else { - payload.Message = "Unable to archive program with active or scheduled classes" - } - - return writeJsonResponse(w, http.StatusOK, payload) -} - -func (srv *server) handleGetProgramArchiveCheck(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - if uint(id) >= CanvasProgramIDOffset { - return writeJsonResponse(w, http.StatusOK, struct { - Facilities []string `json:"facilities"` - }{Facilities: []string{}}) - } - log.add("program_id", id) - facilities, err := srv.store.GetProgramActiveClassFacilities(r.Context(), uint(id)) - if err != nil { - return newDatabaseServiceError(err) - } - payload := struct { - Facilities []string `json:"facilities"` - }{ - Facilities: facilities, - } - return writeJsonResponse(w, http.StatusOK, payload) -} - -func (srv *server) handleGetProgramDeleteCheck(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - if uint(id) >= CanvasProgramIDOffset { - return writeJsonResponse(w, http.StatusOK, struct { - CanDelete bool `json:"can_delete"` - Blockers DeleteBlockingChildren `json:"blockers"` - }{CanDelete: false, Blockers: DeleteBlockingChildren{}}) - } - log.add("program_id", id) - blockers, err := srv.store.ProgramBlockingChildren(id) - if err != nil { - return newDatabaseServiceError(err) - } - payload := struct { - CanDelete bool `json:"can_delete"` - Blockers DeleteBlockingChildren `json:"blockers"` - }{ - CanDelete: !blockers.HasAny(), - Blockers: blockers, - } - return writeJsonResponse(w, http.StatusOK, payload) -} - -func (srv *server) handleDeleteProgram(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - log.add("program_id", id) - - blockers, err := srv.store.ProgramBlockingChildren(id) - if err != nil { - return newDatabaseServiceError(err) - } - if blockers.HasAny() { - log.info("program delete blocked by child records") - return writeDeleteConflictResponse(w, "cannot delete: program has child records", blockers) - } - - if err = srv.store.DeleteProgram(id); err != nil { - return newDatabaseServiceError(err) - } - log.info("Program deleted") - return writeJsonResponse(w, http.StatusNoContent, "Program deleted successfully") -} - -func (srv *server) handleGetProgramHistory(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "program ID") - } - if uint(id) >= CanvasProgramIDOffset { - args := getQueryContext(r) - return writePaginatedResponse(w, http.StatusOK, - []ChangeLogEntry{}, - newPaginationInfo(1, args.PerPage, 0)) - } - args := getQueryContext(r) - categories := r.URL.Query()["categories"] - historyEvents, err := srv.store.GetChangeLogEntries(&args, "programs", id, categories) - if err != nil { - return err - } - pageMeta, createdByDetails, err := srv.getCreatedByForHistory(id, "programs", args.IntoMeta(), &args, len(historyEvents), categories) - if err != nil { - return newDatabaseServiceError(err) - } - if createdByDetails.Action != "" { - historyEvents = append(historyEvents, createdByDetails) - } - return writePaginatedResponse(w, http.StatusOK, historyEvents, pageMeta) -} - -func (srv *server) getCreatedByForHistory(id int, tableName string, pageMeta paginationMeta, args *QueryContext, numOfHistoryEvents int, categories []string) (paginationMeta, ActivityHistoryResponse, error) { - var ( - createdByDetails ActivityHistoryResponse - err error - ) - if int(args.Total) == args.PerPage { - pageMeta.LastPage++ - pageMeta.Total++ - } - if (args.Total == 0 || (int64(args.Page) == int64(pageMeta.LastPage) && numOfHistoryEvents < args.PerPage)) && (len(categories) == 0 || slices.Contains(categories, "info")) { //add get class created by here - switch tableName { - case TableNamePrograms: - createdByDetails, err = srv.store.GetProgramCreatedAtAndBy(id, args) - if err != nil { - return pageMeta, createdByDetails, newDatabaseServiceError(err) - } - prog := "program" - createdByDetails.FieldName = &prog - case TableNameCohort: - createdByDetails, err = srv.store.GetClassCreatedAtAndBy(id, args) - if err != nil { - return pageMeta, createdByDetails, newDatabaseServiceError(err) - } - class := "class" - createdByDetails.FieldName = &class - default: - return pageMeta, createdByDetails, newBadRequestServiceError(errors.New("table name not recognized"), fmt.Sprintf("table name %s not recognized", tableName)) - } - createdByDetails.Action = ProgClassHistory - //count this record only if there are history events - if args.Total > 0 { - pageMeta.Total++ - } - } - return pageMeta, createdByDetails, nil -} - -func (srv *server) handleExportProgramCSV(w http.ResponseWriter, r *http.Request, log sLog) error { - queryCtx := getQueryContext(r) - csvData, err := srv.store.GetProgramsCSVData(&queryCtx) - if err != nil { - return newDatabaseServiceError(err) - } - toCSV, err := ProgramDataToCSVFormat(csvData) - if err != nil { - return newInternalServerServiceError(err, "Failed to convert program data to CSV format") - } - date := time.Now().Format("2006-01-02") - w.Header().Set("Content-Type", "text/csv") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"Program-Data-%s.csv\"", date)) - w.WriteHeader(http.StatusOK) - writer := csv.NewWriter(w) - - writeErr := writer.WriteAll(toCSV) - if writeErr != nil { - return newInternalServerServiceError(writeErr, "Failed to write CSV data") - } - return nil -} - -func (srv *server) handleGetProgramFilters(w http.ResponseWriter, r *http.Request, log sLog) error { - type EnumResponse struct { - FundingTypes []FundingType `json:"funding_types"` - ProgramTypes []ProgType `json:"program_types"` - CreditTypes []CreditType `json:"credit_types"` - } - resp := EnumResponse{ - FundingTypes: AllFundingTypes, - ProgramTypes: AllProgTypes, - CreditTypes: AllCreditTypes, - } - return writeJsonResponse(w, http.StatusOK, resp) -} diff --git a/backend/src/modules/programs/programs_insights_store.go b/backend/src/modules/programs/programs_insights_store.go deleted file mode 100644 index 190803d95..000000000 --- a/backend/src/modules/programs/programs_insights_store.go +++ /dev/null @@ -1,558 +0,0 @@ -package programs - -import ( - "sort" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/users" - "gorm.io/gorm" -) - -const minEnrolleesForRanking = 5 - -func (db *Store) piResidentScope(args *QueryContext, facilityID *uint) *gorm.DB { - tx := db.WithContext(args.Ctx).Model(&User{}). - Where("role = ? AND deactivated_at IS NULL", users.Student) - if facilityID != nil { - tx = tx.Where("facility_id = ?", *facilityID) - } - return tx -} - -// piEnrollmentResidentScope scopes program_class_enrollments to rows belonging -// to active, resident (Student) users, optionally within one facility. Shared -// base for the active-residents and ever-enrolled-residents counts, which -// differ only in their enrollment-status filter. -func (db *Store) piEnrollmentResidentScope(args *QueryContext, facilityID *uint) *gorm.DB { - tx := db.WithContext(args.Ctx).Table("program_class_enrollments pce"). - Joins("JOIN users u ON u.id = pce.user_id"). - Where("u.role = ? AND u.deactivated_at IS NULL", users.Student) - if facilityID != nil { - tx = tx.Where("u.facility_id = ?", *facilityID) - } - return tx -} - -func (db *Store) GetProgramEngagementOverview(args *QueryContext, start, end *time.Time, facilityID *uint) (ProgramEngagementOverview, error) { - var overview ProgramEngagementOverview - - if err := db.piResidentScope(args, facilityID).Count(&overview.TotalResidents).Error; err != nil { - return overview, newGetRecordsDBError(err, "users") - } - - // "Active" means engaged with a program at any point during the selected - // window (enrollment overlaps [start, end)), not just currently-Enrolled - // status -- a resident who completed a program mid-range was still active - // during that range. - activeTx := db.piEnrollmentResidentScope(args, facilityID). - Where("pce.enrolled_at IS NOT NULL") - if end != nil { - activeTx = activeTx.Where("pce.enrolled_at < ?", *end) - } - if start != nil { - activeTx = activeTx.Where("pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at >= ?", *start) - } - if err := activeTx.Distinct("pce.user_id").Count(&overview.ActiveResidents).Error; err != nil { - return overview, newGetRecordsDBError(err, "program_class_enrollments") - } - - var everEnrolledCount int64 - everTx := db.piEnrollmentResidentScope(args, facilityID) - if err := everTx.Distinct("pce.user_id").Count(&everEnrolledCount).Error; err != nil { - return overview, newGetRecordsDBError(err, "program_class_enrollments") - } - overview.NeverEngagedResidents = overview.TotalResidents - everEnrolledCount - if overview.NeverEngagedResidents < 0 { - overview.NeverEngagedResidents = 0 - } - - // Ever-enrolled residents who aren't active in this window: completed, - // dropped, or otherwise engaged outside the selected range. - overview.PreviouslyEngagedResidents = everEnrolledCount - overview.ActiveResidents - if overview.PreviouslyEngagedResidents < 0 { - overview.PreviouslyEngagedResidents = 0 - } - - type progRow struct { - ProgramID uint - ProgramName string - Status ProgramEnrollmentStatus - EnrolledAt *time.Time - } - rows := make([]progRow, 0) - rowsTx := db.WithContext(args.Ctx).Table("program_class_enrollments pce"). - Select("p.id as program_id, p.name as program_name, pce.enrollment_status as status, pce.enrolled_at"). - Joins("JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Joins("JOIN programs p ON p.id = pc.program_id"). - Where("pce.enrolled_at IS NOT NULL") - if facilityID != nil { - rowsTx = rowsTx.Where("pc.facility_id = ?", *facilityID) - } - if start != nil && end != nil { - rowsTx = rowsTx.Where("pce.enrolled_at >= ? AND pce.enrolled_at < ?", *start, *end) - } - if err := rowsTx.Scan(&rows).Error; err != nil { - return overview, newGetRecordsDBError(err, "program_class_enrollments") - } - - type progAgg struct { - name string - enrolled int64 - completed int64 - } - aggByID := make(map[uint]*progAgg) - order := make([]uint, 0) - for _, row := range rows { - agg, ok := aggByID[row.ProgramID] - if !ok { - agg = &progAgg{name: row.ProgramName} - aggByID[row.ProgramID] = agg - order = append(order, row.ProgramID) - } - agg.enrolled++ - if row.Status == EnrollmentCompleted { - agg.completed++ - } - } - - ranked := make([]ProgramCompletionRank, 0) - for _, id := range order { - agg := aggByID[id] - if agg.enrolled < minEnrolleesForRanking { - continue - } - ranked = append(ranked, ProgramCompletionRank{ - ProgramName: agg.name, - Enrolled: agg.enrolled, - Completed: agg.completed, - CompletionRate: float64(agg.completed) / float64(agg.enrolled) * 100, - }) - } - sort.Slice(ranked, func(i, j int) bool { - return ranked[i].CompletionRate > ranked[j].CompletionRate - }) - overview.TopPrograms = ranked - - return overview, nil -} - -func (db *Store) GetSecondProgramEnrollmentRates(args *QueryContext, facilityID *uint) ([]SecondProgramEnrollmentRow, error) { - type enrollmentRow struct { - UserID uint - FacilityID uint - FacilityName string - ProgramID uint - Status ProgramEnrollmentStatus - EnrolledAt *time.Time - EnrollmentEndedAt *time.Time - } - rows := make([]enrollmentRow, 0) - tx := db.WithContext(args.Ctx).Table("program_class_enrollments pce"). - Select("pce.user_id, pc.facility_id, f.name as facility_name, pc.program_id, pce.enrollment_status as status, pce.enrolled_at, pce.enrollment_ended_at"). - Joins("JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Joins("JOIN facilities f ON f.id = pc.facility_id") - if facilityID != nil { - tx = tx.Where("pc.facility_id = ?", *facilityID) - } - if err := tx.Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - - programTypeByID, err := db.programTypeLabelsByProgramID(args) - if err != nil { - return nil, err - } - - type userEnrollment struct { - facilityID uint - facilityName string - programID uint - status ProgramEnrollmentStatus - enrolledAt *time.Time - endedAt *time.Time - } - byUser := make(map[uint][]userEnrollment) - for _, row := range rows { - byUser[row.UserID] = append(byUser[row.UserID], userEnrollment{ - facilityID: row.FacilityID, facilityName: row.FacilityName, programID: row.ProgramID, - status: row.Status, enrolledAt: row.EnrolledAt, endedAt: row.EnrollmentEndedAt, - }) - } - - type key struct { - facilityName string - programType string - } - completedFirst := make(map[key]int64) - enrolledSecond := make(map[key]int64) - - for _, enrollments := range byUser { - // Find this user's earliest completion by EnrollmentEndedAt. - var first *userEnrollment - for i := range enrollments { - e := &enrollments[i] - if e.status != EnrollmentCompleted || e.endedAt == nil { - continue - } - if first == nil || e.endedAt.Before(*first.endedAt) { - first = e - } - } - if first == nil { - continue - } - k := key{facilityName: first.facilityName, programType: programTypeLabelFor(programTypeByID, first.programID)} - completedFirst[k]++ - - for _, e := range enrollments { - if e.programID == first.programID { - continue // re-enrollment in the same program isn't a second *program* - } - if e.enrolledAt != nil && e.enrolledAt.After(*first.endedAt) { - enrolledSecond[k]++ - break - } - } - } - - result := make([]SecondProgramEnrollmentRow, 0, len(completedFirst)) - for k, completed := range completedFirst { - second := enrolledSecond[k] - result = append(result, SecondProgramEnrollmentRow{ - FacilityName: k.facilityName, - ProgramType: k.programType, - CompletedFirst: completed, - EnrolledSecond: second, - Rate: float64(second) / float64(completed) * 100, - }) - } - sort.Slice(result, func(i, j int) bool { - return result[i].Rate > result[j].Rate - }) - return result, nil -} - -// programTypeLabel returns a deterministic single label for a program that -// may have multiple attached ProgramTypes: alphabetically first. Most -// programs have exactly one type; this is a pragmatic tie-break, not a bug. -func programTypeLabel(types []ProgramType) string { - if len(types) == 0 { - return "Other" - } - labels := make([]string, 0, len(types)) - for _, t := range types { - labels = append(labels, t.ProgramType.HumanReadable()) - } - sort.Strings(labels) - return labels[0] -} - -// programTypeLabelsByProgramID returns each program's deterministic type -// label (see programTypeLabel), keyed by program ID. -func (db *Store) programTypeLabelsByProgramID(args *QueryContext) (map[uint]string, error) { - var programs []Program - if err := db.WithContext(args.Ctx).Preload("ProgramTypes").Find(&programs).Error; err != nil { - return nil, newGetRecordsDBError(err, "programs") - } - labels := make(map[uint]string, len(programs)) - for _, p := range programs { - labels[p.ID] = programTypeLabel(p.ProgramTypes) - } - return labels, nil -} - -// programTypeLabelFor looks up a program's type label, falling back to -// "Other" when the program isn't in the map -- e.g. a soft-deleted program -// (DeleteProgram leaves its enrollments/cohorts in place) that -// programTypeLabelsByProgramID's Find no longer returns. -func programTypeLabelFor(labels map[uint]string, programID uint) string { - if label, ok := labels[programID]; ok && label != "" { - return label - } - return "Other" -} - -const minEnrolledPerMatrixCell = 3 - -// GetProgramCompletionMatrix returns, for every (program, cohort facility) -// pair, the lifetime enrollment/completion counts and completion rate, -// flagging cells below minEnrolledPerMatrixCell as statistically -// insufficient and reporting each sufficient cell's delta from its own -// facility's overall completion rate. Facility scoping uses the enrollment's -// cohort facility (pc.facility_id), matching GetSecondProgramEnrollmentRates, -// not the resident's home facility. -func (db *Store) GetProgramCompletionMatrix(args *QueryContext, facilityID *uint) ([]ProgramCompletionMatrixCell, error) { - type cellRow struct { - ProgramName string - FacilityID uint - FacilityName string - Status ProgramEnrollmentStatus - } - rows := make([]cellRow, 0) - tx := db.WithContext(args.Ctx).Table("program_class_enrollments pce"). - Select("p.name as program_name, pc.facility_id, f.name as facility_name, pce.enrollment_status as status"). - Joins("JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Joins("JOIN programs p ON p.id = pc.program_id"). - Joins("JOIN facilities f ON f.id = pc.facility_id") - if facilityID != nil { - tx = tx.Where("pc.facility_id = ?", *facilityID) - } - if err := tx.Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - - type cellKey struct { - program string - facility uint - } - type cellAgg struct { - facilityName string - enrolled int64 - completed int64 - } - cells := make(map[cellKey]*cellAgg) - facilityTotals := make(map[uint]*cellAgg) // enrolled/completed totals per facility, for the average - order := make([]cellKey, 0) - - for _, row := range rows { - k := cellKey{program: row.ProgramName, facility: row.FacilityID} - agg, ok := cells[k] - if !ok { - agg = &cellAgg{facilityName: row.FacilityName} - cells[k] = agg - order = append(order, k) - } - agg.enrolled++ - if row.Status == EnrollmentCompleted { - agg.completed++ - } - - fAgg, ok := facilityTotals[row.FacilityID] - if !ok { - fAgg = &cellAgg{} - facilityTotals[row.FacilityID] = fAgg - } - fAgg.enrolled++ - if row.Status == EnrollmentCompleted { - fAgg.completed++ - } - } - - result := make([]ProgramCompletionMatrixCell, 0, len(order)) - for _, k := range order { - agg := cells[k] - insufficient := agg.enrolled < minEnrolledPerMatrixCell - rate := 0.0 - delta := 0.0 - if !insufficient { - rate = float64(agg.completed) / float64(agg.enrolled) * 100 - fTotal := facilityTotals[k.facility] - facilityAvg := float64(fTotal.completed) / float64(fTotal.enrolled) * 100 - delta = rate - facilityAvg - } - result = append(result, ProgramCompletionMatrixCell{ - ProgramName: k.program, - FacilityID: k.facility, - FacilityName: agg.facilityName, - Enrolled: agg.enrolled, - Completed: agg.completed, - CompletionRate: rate, - Insufficient: insufficient, - DeltaFromFacilityAverage: delta, - }) - } - return result, nil -} - -func loadBucketLabel(count int64) string { - switch { - case count <= 0: - return "0" - case count == 1: - return "1" - case count == 2: - return "2" - case count == 3: - return "3" - default: - return "4+" - } -} - -// GetProgramLoadDistribution buckets each resident by their current active -// enrollment count (0/1/2/3/4+), a current-state snapshot with no date -// range. Facility scoping is deliberately mixed: the resident population -// (who counts as a "0", and which facility a resident's bucket is attributed -// to) uses the resident's home facility (u.facility_id), while the active -// enrollment count per resident is joined through the enrollment's cohort -// facility (pc.facility_id). This matches GetProgramEngagementOverview's -// resident-population convention and GetSecondProgramEnrollmentRates' -// cohort-facility convention for enrollment counting, applied together here. -func (db *Store) GetProgramLoadDistribution(args *QueryContext, facilityID *uint) (ProgramLoadDistribution, error) { - var dist ProgramLoadDistribution - - type resident struct { - ID uint - FacilityID uint - FacilityName string - } - residents := make([]resident, 0) - rtx := db.WithContext(args.Ctx).Table("users u"). - Select("u.id, u.facility_id, f.name as facility_name"). - Joins("JOIN facilities f ON f.id = u.facility_id"). - Where("u.role = ? AND u.deactivated_at IS NULL", users.Student) - if facilityID != nil { - rtx = rtx.Where("u.facility_id = ?", *facilityID) - } - if err := rtx.Scan(&residents).Error; err != nil { - return dist, newGetRecordsDBError(err, "users") - } - - type activeCount struct { - UserID uint - Cnt int64 - } - counts := make([]activeCount, 0) - ctx := db.WithContext(args.Ctx).Table("program_class_enrollments pce"). - Select("pce.user_id, count(*) as cnt"). - Joins("JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Where("pce.enrollment_status = ? AND pce.enrollment_ended_at IS NULL", Enrolled) - if facilityID != nil { - ctx = ctx.Where("pc.facility_id = ?", *facilityID) - } - if err := ctx.Group("pce.user_id").Scan(&counts).Error; err != nil { - return dist, newGetRecordsDBError(err, "program_class_enrollments") - } - countByUser := make(map[uint]int64, len(counts)) - for _, c := range counts { - countByUser[c.UserID] = c.Cnt - } - - statewide := map[string]int64{"0": 0, "1": 0, "2": 0, "3": 0, "4+": 0} - type facilityAgg struct { - name string - zero, one, two, three, four int64 - } - byFacility := make(map[uint]*facilityAgg) - facilityOrder := make([]uint, 0) - - for _, r := range residents { - bucket := loadBucketLabel(countByUser[r.ID]) - statewide[bucket]++ - - agg, ok := byFacility[r.FacilityID] - if !ok { - agg = &facilityAgg{name: r.FacilityName} - byFacility[r.FacilityID] = agg - facilityOrder = append(facilityOrder, r.FacilityID) - } - switch bucket { - case "0": - agg.zero++ - case "1": - agg.one++ - case "2": - agg.two++ - case "3": - agg.three++ - default: - agg.four++ - } - } - - dist.Statewide = []ProgramLoadBucket{ - {Bucket: "0", Count: statewide["0"]}, - {Bucket: "1", Count: statewide["1"]}, - {Bucket: "2", Count: statewide["2"]}, - {Bucket: "3", Count: statewide["3"]}, - {Bucket: "4+", Count: statewide["4+"]}, - } - dist.ByFacility = make([]ProgramLoadFacilityRow, 0, len(facilityOrder)) - for _, id := range facilityOrder { - agg := byFacility[id] - dist.ByFacility = append(dist.ByFacility, ProgramLoadFacilityRow{ - FacilityID: id, FacilityName: agg.name, - Zero: agg.zero, One: agg.one, Two: agg.two, Three: agg.three, FourPlus: agg.four, - Total: agg.zero + agg.one + agg.two + agg.three + agg.four, - }) - } - return dist, nil -} - -const maxIndividualProgramTypes = 4 - -// GetEnrollmentByProgramType returns lifetime enrollment/completion counts -// per program type label, capped to the maxIndividualProgramTypes -// highest-enrollment types with the remainder rolled into a single "Other" -// bucket (see programTypeLabel). Facility scoping uses the enrollment's -// cohort facility (pc.facility_id), matching GetSecondProgramEnrollmentRates -// and GetProgramCompletionMatrix. -func (db *Store) GetEnrollmentByProgramType(args *QueryContext, facilityID *uint) ([]ProgramTypeEnrollment, error) { - type enrollmentRow struct { - ProgramID uint - Status ProgramEnrollmentStatus - } - rows := make([]enrollmentRow, 0) - tx := db.WithContext(args.Ctx).Table("program_class_enrollments pce"). - Select("pc.program_id, pce.enrollment_status as status"). - Joins("JOIN program_class_cohorts pc ON pc.id = pce.cohort_id") - if facilityID != nil { - tx = tx.Where("pc.facility_id = ?", *facilityID) - } - if err := tx.Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - - programTypeByID, err := db.programTypeLabelsByProgramID(args) - if err != nil { - return nil, err - } - - type typeAgg struct { - enrolled int64 - completed int64 - } - byType := make(map[string]*typeAgg) - for _, row := range rows { - label := programTypeLabelFor(programTypeByID, row.ProgramID) - agg, ok := byType[label] - if !ok { - agg = &typeAgg{} - byType[label] = agg - } - agg.enrolled++ - if row.Status == EnrollmentCompleted { - agg.completed++ - } - } - - all := make([]ProgramTypeEnrollment, 0, len(byType)) - for label, agg := range byType { - all = append(all, ProgramTypeEnrollment{ - ProgramType: label, - Enrolled: agg.enrolled, - Completed: agg.completed, - Rate: float64(agg.completed) / float64(agg.enrolled) * 100, - }) - } - sort.Slice(all, func(i, j int) bool { - if all[i].Enrolled != all[j].Enrolled { - return all[i].Enrolled > all[j].Enrolled - } - return all[i].ProgramType < all[j].ProgramType - }) - - if len(all) <= maxIndividualProgramTypes { - return all, nil - } - result := make([]ProgramTypeEnrollment, 0, maxIndividualProgramTypes+1) - result = append(result, all[:maxIndividualProgramTypes]...) - other := ProgramTypeEnrollment{ProgramType: "Other"} - for _, rest := range all[maxIndividualProgramTypes:] { - other.Enrolled += rest.Enrolled - other.Completed += rest.Completed - } - other.Rate = float64(other.Completed) / float64(other.Enrolled) * 100 - result = append(result, other) - return result, nil -} diff --git a/backend/src/modules/programs/programs_store.go b/backend/src/modules/programs/programs_store.go deleted file mode 100644 index 2fedef2b5..000000000 --- a/backend/src/modules/programs/programs_store.go +++ /dev/null @@ -1,1057 +0,0 @@ -package programs - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/users" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -func (db *Store) GetProgramByID(id int) (*Program, error) { - content := &Program{} - if err := db.Preload("ProgramTypes").Preload("ProgramCreditTypes").Preload("FacilitiesPrograms.Facility").First(content, id).Error; err != nil { - return nil, newNotFoundDBError(err, "programs") - } - - for _, fp := range content.FacilitiesPrograms { - if fp.Facility != nil { - content.Facilities = append(content.Facilities, *fp.Facility) - } - } - - return content, nil -} - -func (db *Store) GetProgramNamesByIDs(ids []uint) ([]string, error) { - var names []string - if len(ids) == 0 { - return names, nil - } - if err := db.Model(&Program{}).Where("id IN ?", ids).Order("name").Pluck("name", &names).Error; err != nil { - return nil, newGetRecordsDBError(err, "programs") - } - return names, nil -} - -func (db *Store) FetchEnrollmentMetrics(programID int, facilityId uint) (*ProgramOverviewResponse, error) { - var metrics ProgramOverviewResponse - - query := ` - COUNT(CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) THEN 1 END) AS active_enrollments, - COUNT(CASE WHEN pce.enrollment_status = 'Completed' THEN 1 END) AS completions, - COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END) AS total_enrollments, - COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) THEN pce.user_id END) as active_residents, - COUNT(CASE WHEN pce.enrollment_status = 'Completed' AND pce.enrollment_ended_at IS NOT NULL AND ` + rateEligibleClassStatusSQL + ` THEN 1 END) * 100.0 - / NULLIF(COUNT(CASE WHEN pce.enrollment_status IN (` + completedOutcomeStatusSQLList() + `) AND pce.enrollment_ended_at IS NOT NULL AND ` + rateEligibleClassStatusSQL + ` THEN 1 END), 0) AS completion_rate - ` - - tx := db.Table("program_class_enrollments pce"). - Select(query). - Joins("JOIN program_class_cohorts pc ON pce.cohort_id = pc.id"). - Where("pc.program_id = ?", programID) - if facilityId != 0 { - tx = tx.Where("pc.facility_id = ?", facilityId) - } - - if err := tx.Scan(&metrics).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - - partialAttendanceSQL := buildPartialAttendanceSQL(db.Name(), "pcea") - attendanceQuery := fmt.Sprintf(` - SELECT - SUM( - CASE - WHEN pcea.attendance_status = 'present' THEN 1 - WHEN pcea.attendance_status = 'partial' THEN %s - ELSE 0 - END - ) * 100.0 / - NULLIF(COUNT(CASE WHEN pcea.attendance_status IS NOT NULL AND pcea.attendance_status != '' THEN 1 END), 0) AS attendance_rate - FROM program_class_cohorts pc - LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id - LEFT JOIN program_class_events pcev ON pcev.cohort_id = pc.id - LEFT JOIN program_class_event_attendance pcea ON pcea.event_id = pcev.id AND pcea.user_id = pce.user_id AND pcea.deleted_at IS NULL - WHERE pc.program_id = ?`, partialAttendanceSQL) - - attendanceArgs := []any{programID} - if facilityId != 0 { - attendanceQuery += " AND pc.facility_id = ?" - attendanceArgs = append(attendanceArgs, facilityId) - } - - var attendanceResult struct { - AttendanceRate *float64 `json:"attendance_rate"` - } - if err := db.Raw(attendanceQuery, attendanceArgs...).Scan(&attendanceResult).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_event_attendance") - } - metrics.AttendanceRate = attendanceResult.AttendanceRate - return &metrics, nil -} - -func (db *Store) GetActiveClassFacilityIDs(ctx context.Context, id int) ([]int, error) { - activeClassFacilityIDs := make([]int, 0) - if err := db.WithContext(ctx).Model(&ProgramClassCohort{}).Select("DISTINCT facility_id"). - Where("program_id = ? and status in ('Active', 'Scheduled')", id). - Scan(&activeClassFacilityIDs).Error; err != nil { - return nil, newGetRecordsDBError(err, "program class cohort") - } - return activeClassFacilityIDs, nil -} - -func (db *Store) GetProgramActiveClassFacilities(ctx context.Context, id uint) ([]string, error) { - var facilities []string - if err := db.WithContext(ctx).Table("program_class_cohorts"). - Joins("JOIN facilities ON facilities.id = program_class_cohorts.facility_id"). - Where("program_class_cohorts.program_id = ? AND program_class_cohorts.status IN (?, ?) AND program_class_cohorts.archived_at IS NULL", id, - Active, Scheduled). - Distinct(). - Pluck("facilities.name", &facilities).Error; err != nil { - return nil, newGetRecordsDBError(err, fmt.Sprintf("program %d active facilities", id)) - } - return facilities, nil -} - -func (db *Store) GetPrograms(args *QueryContext) ([]Program, error) { - content := make([]Program, 0, args.PerPage) - tx := db.WithContext(args.Ctx).Model(&Program{}). - Preload("ProgramTypes"). - Preload("ProgramCreditTypes"). - Preload("FacilitiesPrograms.Facility") - if len(args.Tags) > 0 { - tx = tx.Joins("JOIN program_types t ON t.program_id = programs.id").Where("t.id IN (?)", args.Tags) - } - tx = tx.Order(args.OrderClause("programs.created_at desc")) - - if args.Search != "" { - tx = tx.Where("LOWER(name) LIKE ? OR LOWER(description) LIKE ? ", args.SearchQuery(), args.SearchQuery()) - } - - if err := tx.Count(&args.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "programs") - } - if err := tx.Limit(args.PerPage).Offset(args.CalcOffset()).Find(&content).Error; err != nil { - return nil, newGetRecordsDBError(err, "programs") - } - for i := range content { - for _, fp := range content[i].FacilitiesPrograms { - if fp.Facility != nil { - content[i].Facilities = append(content[i].Facilities, *fp.Facility) - } - } - } - return content, nil -} - -func (db *Store) CreateProgram(content *Program) error { - err := Validate().Struct(content) - if err != nil { - return newDBError(err, "create programs validation error") - } - if err := db.Create(content).Error; err != nil { - return newCreateDBError(err, "programs") - } - return nil -} - -// UpdateProgram applies a partial update to a program. -// -// hasProgramCompletion carries the tri-state the wire needs: nil means the caller did not -// send the field and the stored value must survive, non-nil is an explicit set. It cannot -// be read off program.HasProgramCompletion because updateStruct ALWAYS copies -// bools, so an omitted field is indistinguishable from an explicit false by then. -// -// ⚠️ The read is inside the transaction and takes a row lock, and that is load-bearing. -// -// This function is a read-modify-write: it reads the program, merges the caller's -// non-zero fields over it, and writes the merged row back. With the read outside the -// transaction, two overlapping updates each read the old row and the slower writer -// silently reverses the faster one -- a lost update, for EVERY field, not just the -// completion flag. Locking the row makes the second update wait for the first. -// -// getProgramByIDForUpdate is GetProgramByID scoped to a transaction and holding a row -// lock, so a read-modify-write cannot interleave with another writer. Preloads run as -// their own SELECTs and are deliberately not locked -- only the programs row needs it. -func getProgramByIDForUpdate(tx *gorm.DB, id int) (*Program, error) { - content := &Program{} - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Preload("ProgramTypes").Preload("ProgramCreditTypes").Preload("FacilitiesPrograms.Facility"). - First(content, id).Error; err != nil { - return nil, newNotFoundDBError(err, "programs") - } - for _, fp := range content.FacilitiesPrograms { - if fp.Facility != nil { - content.Facilities = append(content.Facilities, *fp.Facility) - } - } - return content, nil -} - -func (db *Store) UpdateProgram(program *Program, facilityIds []int, hasProgramCompletion *bool) (*Program, error) { - var allChanges []ChangeLogEntry - //begin the transaction - trans := db.Begin() //only need to pass context here, it will be used/shared within the transaction - if trans.Error != nil { - return nil, newDBError(trans.Error, "unable to start the database transaction") - } - updatePrg, err := getProgramByIDForUpdate(trans, int(program.ID)) - if err != nil { - trans.Rollback() - return nil, err - } - // Resolve the flag against the LOCKED row, and do it before the change log is - // generated -- otherwise an omitted field is diffed as true -> false and logged as a - // change that never happens. - if hasProgramCompletion != nil { - program.HasProgramCompletion = *hasProgramCompletion - } else { - program.HasProgramCompletion = updatePrg.HasProgramCompletion - } - ignoredFieldNames := []string{"create_user_id", "update_user_id", "program_types", "credit_types", "facilities", "archived_at"} - programLogEntries := GenerateChangeLogEntries(updatePrg, program, "programs", updatePrg.ID, DerefUint(program.UpdateUserID), ignoredFieldNames) - allChanges = append(allChanges, programLogEntries...) - updateStruct(&updatePrg, &program) - facLogEntries, err := updateFacilitiesPrograms(trans, updatePrg, facilityIds) - if err != nil { - trans.Rollback() - return nil, err - } - allChanges = append(allChanges, facLogEntries...) - prgTypeLogEntries, err := updateProgramTypes(trans, updatePrg, program.ProgramTypes) - if err != nil { - trans.Rollback() - return nil, err - } - allChanges = append(allChanges, prgTypeLogEntries...) - creditTypeLogEntries, err := updateProgramCreditTypes(trans, updatePrg, program.ProgramCreditTypes) - if err != nil { - trans.Rollback() - return nil, err - } - allChanges = append(allChanges, creditTypeLogEntries...) - // ⚠️ This Select list is hand-maintained and fails OPEN: a field missing from it is - // silently NOT persisted -- the update succeeds and the value is dropped. Any new - // Program column the edit form can change must be added here. - if err := trans.Omit("Facilities", "CreatedAt").Select("IsActive", "Name", "Description", "FundingType", "UpdateUserID", "HasProgramCompletion").Updates(&updatePrg).Error; err != nil { - trans.Rollback() - return nil, newUpdateDBError(err, "programs") - } - - if len(allChanges) > 0 { - if err := trans.Create(&allChanges).Error; err != nil { - trans.Rollback() - return nil, newCreateDBError(err, "change_log_entries") - } - } - //end transaction - if err := trans.Commit().Error; err != nil { - return nil, newDBError(err, "unable to commit the database transaction") - } - return updatePrg, nil -} - -func updateFacilitiesPrograms(trans *gorm.DB, updateProgram *Program, facilityIds []int) ([]ChangeLogEntry, error) { - var ( - currentFacPrgs []FacilitiesPrograms - logEntries []ChangeLogEntry - facilityID uint - ) - if err := trans.Where("program_id = ?", updateProgram.ID).Find(¤tFacPrgs).Error; err != nil { - return nil, newGetRecordsDBError(err, "facilities_programs") - } - - currentFacPrgsMap := make(map[uint]struct{}, len(currentFacPrgs)) //added light weight map used for lookup - for _, fp := range currentFacPrgs { - currentFacPrgsMap[fp.FacilityID] = struct{}{} - } - - updateFacPrgsMap := make(map[uint]struct{}, len(facilityIds)) //added light weight map used for lookup - for _, facId := range facilityIds { - facilityID = uint(facId) - updateFacPrgsMap[facilityID] = struct{}{} - if _, ok := currentFacPrgsMap[facilityID]; !ok { - logEntries = append(logEntries, *NewChangeLogEntry("programs", "facility_id", nil, StringPtr(strconv.Itoa(int(facilityID))), updateProgram.ID, DerefUint(updateProgram.UpdateUserID))) - if err := trans.Create(&FacilitiesPrograms{ - ProgramID: updateProgram.ID, - FacilityID: facilityID, - }).Error; err != nil { - return nil, newCreateDBError(err, "facilities_programs") - } - } - } - - for facId := range currentFacPrgsMap { - if _, ok := updateFacPrgsMap[facId]; !ok { //delete entry - logEntries = append(logEntries, *NewChangeLogEntry("programs", "facility_id", StringPtr(strconv.Itoa(int(facId))), nil, updateProgram.ID, DerefUint(updateProgram.UpdateUserID))) - now := time.Now().UTC() - updates := map[string]any{ - "update_user_id": DerefUint(updateProgram.UpdateUserID), - "deleted_at": gorm.DeletedAt{Time: now, Valid: true}, - } - if err := trans.Model(&FacilitiesPrograms{}). - Where("program_id = ? and facility_id = ?", updateProgram.ID, facId). - Updates(updates).Error; err != nil { - return nil, newDeleteDBError(err, "facilities_programs") - } - } - } - - return logEntries, nil -} - -func updateProgramTypes(trans *gorm.DB, updateProgram *Program, programTypes []ProgramType) ([]ChangeLogEntry, error) { - var ( - currentPrgTypes []ProgramType - logEntries []ChangeLogEntry - progType ProgType - ) - if err := trans.Where("program_id = ?", updateProgram.ID).Find(¤tPrgTypes).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_types") - } - - currentPrgTypesMap := make(map[ProgType]struct{}, len(currentPrgTypes)) //added light weight map used for lookup - for _, pt := range currentPrgTypes { - currentPrgTypesMap[pt.ProgramType] = struct{}{} - } - - updatePrgTypesMap := make(map[ProgType]struct{}, len(programTypes)) //added light weight map used for lookup - for _, pt := range programTypes { - progType = pt.ProgramType - updatePrgTypesMap[progType] = struct{}{} - if _, ok := currentPrgTypesMap[progType]; !ok { //type doesn't exist create it - logEntries = append(logEntries, *NewChangeLogEntry("programs", "program_type", nil, StringPtr(string(progType)), updateProgram.ID, DerefUint(updateProgram.UpdateUserID))) - if err := trans.Create(&ProgramType{ - ProgramID: updateProgram.ID, - ProgramType: progType, - }).Error; err != nil { - return nil, newCreateDBError(err, "program_types") - } - } - } - - for prgType := range currentPrgTypesMap { - if _, ok := updatePrgTypesMap[prgType]; !ok { - logEntries = append(logEntries, *NewChangeLogEntry("programs", "program_type", StringPtr(string(prgType)), nil, updateProgram.ID, DerefUint(updateProgram.UpdateUserID))) - if err := trans.Where("program_id = ? and program_type = ?", updateProgram.ID, prgType).Delete(&ProgramType{}).Error; err != nil { - return nil, newDeleteDBError(err, "program_types") - } - } - } - return logEntries, nil -} - -func updateProgramCreditTypes(trans *gorm.DB, updateProgram *Program, programCreditTypes []ProgramCreditType) ([]ChangeLogEntry, error) { - var ( - currentPrgCreditTypes []ProgramCreditType - logEntries []ChangeLogEntry - progCreditType CreditType - ) - if err := trans.Where("program_id = ?", updateProgram.ID).Find(¤tPrgCreditTypes).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_credit_types") - } - - currentPrgCreditTypesMap := make(map[CreditType]struct{}, len(currentPrgCreditTypes)) //added a light weight map used for lookup - for _, ct := range currentPrgCreditTypes { - currentPrgCreditTypesMap[ct.CreditType] = struct{}{} - } - - updatePrgCreditTypesMap := make(map[CreditType]struct{}, len(programCreditTypes)) //added a light weight map used for lookup - for _, pt := range programCreditTypes { - progCreditType = pt.CreditType - updatePrgCreditTypesMap[progCreditType] = struct{}{} - if _, ok := currentPrgCreditTypesMap[progCreditType]; !ok { //type doesn't exist create it - logEntries = append(logEntries, *NewChangeLogEntry("programs", "credit_type", nil, StringPtr(string(progCreditType)), updateProgram.ID, DerefUint(updateProgram.UpdateUserID))) - if err := trans.Create(&ProgramCreditType{ - ProgramID: updateProgram.ID, - CreditType: progCreditType, - }).Error; err != nil { - return nil, newCreateDBError(err, "program_credit_types") - } - } - } - - for creditType := range currentPrgCreditTypesMap { - if _, ok := updatePrgCreditTypesMap[creditType]; !ok { //type was removed delete it - logEntries = append(logEntries, *NewChangeLogEntry("programs", "credit_type", StringPtr(string(creditType)), nil, updateProgram.ID, DerefUint(updateProgram.UpdateUserID))) - if err := trans.Where("program_id = ? and credit_type = ?", updateProgram.ID, creditType).Delete(&ProgramCreditType{}).Error; err != nil { - return nil, newDeleteDBError(err, "program_credit_types") - } - } - } - return logEntries, nil -} - -func (db *Store) UpdateProgramStatus(programUpdate map[string]any, id uint) ([]string, bool, error) { - var ( - facilities []string - logEntry *ChangeLogEntry - ) - updatePrg, err := db.GetProgramByID(int(id)) - if err != nil { - return nil, false, err - } - if programUpdate["archived_at"] != nil { - err := db.Model(&ProgramClassCohort{}). - Joins("JOIN facilities ON facilities.id = program_class_cohorts.facility_id"). - Where("program_class_cohorts.program_id = ? AND program_class_cohorts.status IN ? AND program_class_cohorts.archived_at IS NULL", id, - []ClassStatus{Active, Scheduled}). - Distinct(). - Pluck("facilities.name", &facilities).Error - - if err != nil { - return nil, false, newGetRecordsDBError(err, "program_classes / facilities") - } - if len(facilities) > 0 { - return facilities, false, nil - } - logEntry = NewChangeLogEntry("programs", "archived_at", nil, StringPtr(programUpdate["archived_at"].(string)), updatePrg.ID, programUpdate["update_user_id"].(uint)) - } else { //is possible that the old value is the same as the new value here when reactivating - oldValue := strconv.FormatBool(updatePrg.IsActive) - newValue := strconv.FormatBool(programUpdate["is_active"].(bool)) - logEntry = NewChangeLogEntry("programs", "is_active", StringPtr(oldValue), StringPtr(newValue), updatePrg.ID, programUpdate["update_user_id"].(uint)) - } - trans := db.Begin() //create transaction - if trans.Error != nil { - return nil, false, newDBError(trans.Error, "unable to start the database transaction") - } - if err := trans.Model(&Program{}). - Where("id = ?", id). - Updates(programUpdate).Error; err != nil { - return nil, false, newUpdateDBError(err, "program status") - } - if err := trans.Create(&logEntry).Error; err != nil { - trans.Rollback() - return nil, false, newCreateDBError(err, "change_log_entries") - } - if err := trans.Commit().Error; err != nil { //commit transaction - return nil, false, newDBError(err, "unable to commit the database transaction") - } - return facilities, true, nil -} - -func (db *Store) DeleteProgram(id int) error { - return db.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("cohort_id IN (SELECT id FROM program_class_cohorts WHERE program_id = ?)", id). - Delete(&ProgramClassEvent{}).Error; err != nil { - return newDeleteDBError(err, "program_class_events") - } - if err := tx.Exec(`DELETE FROM change_log_entries - WHERE (table_name = ? AND parent_ref_id = ?) - OR (table_name = ? - AND parent_ref_id IN (SELECT id FROM program_class_cohorts WHERE program_id = ?)) - OR (table_name = ? - AND parent_ref_id IN (SELECT id FROM program_classes WHERE program_id = ?))`, - TableNamePrograms, id, - TableNameCohort, id, - TableNameClass, id).Error; err != nil { - return newDeleteDBError(err, "change_log_entries") - } - if err := tx.Exec(`DELETE FROM program_classes_history - WHERE (table_name = ? - AND parent_ref_id IN (SELECT id FROM program_class_cohorts WHERE program_id = ?)) - OR (table_name = ? - AND parent_ref_id IN (SELECT id FROM program_classes WHERE program_id = ?)) - OR (table_name = ? AND parent_ref_id = ?)`, - TableNameCohort, id, - TableNameClass, id, - TableNamePrograms, id).Error; err != nil { - return newDeleteDBError(err, "program_classes_history") - } - if err := tx.Delete(&Program{}, id).Error; err != nil { - return newDeleteDBError(err, "programs") - } - return nil - }) -} - -func (db *Store) GetProgramsFacilitiesStats(args *QueryContext, timeFilter int) (ProgramsFacilitiesStats, error) { - var programsFacilitiesStats ProgramsFacilitiesStats - - var totals struct { - TotalPrograms int64 `json:"total_programs"` - TotalEnrollments int64 `json:"total_enrollments"` - } - - if err := db.WithContext(args.Ctx).Model(&Program{}). - Select("COUNT(*) AS total_programs"). - Where("is_active = true AND archived_at IS NULL"). - Scan(&totals.TotalPrograms).Error; err != nil { - return programsFacilitiesStats, newGetRecordsDBError(err, "total active programs") - } - - if err := db.WithContext(args.Ctx).Model(&ProgramClassEnrollment{}). - Select("COUNT(CASE WHEN enrolled_at IS NOT NULL THEN 1 END) AS total_enrollments"). - Scan(&totals.TotalEnrollments).Error; err != nil { - return programsFacilitiesStats, newGetRecordsDBError(err, "total enrollments") - } - - var rateStats struct { - AvgActiveProgramsPerFacility float64 `json:"avg_active_programs_per_facility"` - AttendanceRate float64 `json:"attendance_rate"` - CompletionRate float64 `json:"completion_rate"` - } - - completionTimeFilter := "" - attendanceTimeFilter := "" - if timeFilter > 0 { - cutoffDate := time.Now().AddDate(0, 0, -timeFilter).Format("2006-01-02") - completionTimeFilter = fmt.Sprintf("AND (pce.enrollment_ended_at >= '%s' OR pcev.created_at >= '%s')", cutoffDate, cutoffDate) - attendanceTimeFilter = completionTimeFilter - } - - partialAttendanceSQL := buildPartialAttendanceSQL(db.Name(), "pcea") - var avgResult struct { - TotalAssociations int64 `json:"total_associations"` - TotalFacilities int64 `json:"total_facilities"` - } - - if err := db.WithContext(args.Ctx).Raw(` - SELECT - COUNT(DISTINCT fp.id) as total_associations, - COUNT(DISTINCT f.id) as total_facilities - FROM facilities f - LEFT JOIN facilities_programs fp ON fp.facility_id = f.id - LEFT JOIN programs p ON p.id = fp.program_id AND p.is_active = true AND p.archived_at IS NULL - LEFT JOIN program_class_cohorts pc ON pc.program_id = p.id AND pc.facility_id = f.id AND pc.status = 'Active' - LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id - WHERE pc.id IS NOT NULL AND pce.id IS NOT NULL AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) - `).Scan(&avgResult).Error; err != nil { - return programsFacilitiesStats, newGetRecordsDBError(err, "avg active programs per facility") - } - - if avgResult.TotalFacilities > 0 { - rateStats.AvgActiveProgramsPerFacility = float64(avgResult.TotalAssociations) / float64(avgResult.TotalFacilities) - } else { - rateStats.AvgActiveProgramsPerFacility = 0 - } - - query := fmt.Sprintf(` - SELECT - COALESCE(COUNT(CASE WHEN pce.enrollment_status = 'Completed' AND pce.enrolled_at IS NOT NULL %s THEN 1 END) * 100.0 / - NULLIF(COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END), 0), 0) AS completion_rate, - COALESCE(SUM( - CASE - WHEN pcea.attendance_status = 'present' %s THEN 1 - WHEN pcea.attendance_status = 'partial' %s THEN %s - ELSE 0 - END - ) * 100.0 / - NULLIF(COUNT(CASE WHEN pcea.attendance_status IS NOT NULL AND pcea.attendance_status != '' %s THEN 1 END), 0), 0) AS attendance_rate - FROM program_class_enrollments pce - LEFT JOIN program_class_cohorts pc ON pc.id = pce.cohort_id - LEFT JOIN program_class_events pcev ON pcev.cohort_id = pc.id - LEFT JOIN program_class_event_attendance pcea ON pcea.event_id = pcev.id AND pcea.user_id = pce.user_id AND pcea.deleted_at IS NULL - WHERE pce.enrolled_at IS NOT NULL %s - `, completionTimeFilter, attendanceTimeFilter, attendanceTimeFilter, partialAttendanceSQL, attendanceTimeFilter, completionTimeFilter) - - var completionAttendanceRates struct { - AttendanceRate float64 `json:"attendance_rate"` - CompletionRate float64 `json:"completion_rate"` - } - - if err := db.WithContext(args.Ctx).Raw(query).Scan(&completionAttendanceRates).Error; err != nil { - return programsFacilitiesStats, newGetRecordsDBError(err, "completion and attendance rates") - } - - rateStats.AttendanceRate = completionAttendanceRates.AttendanceRate - rateStats.CompletionRate = completionAttendanceRates.CompletionRate - - programsFacilitiesStats.TotalPrograms = &totals.TotalPrograms - programsFacilitiesStats.TotalEnrollments = &totals.TotalEnrollments - programsFacilitiesStats.AvgActiveProgramsPerFacility = &rateStats.AvgActiveProgramsPerFacility - programsFacilitiesStats.AttendanceRate = &rateStats.AttendanceRate - programsFacilitiesStats.CompletionRate = &rateStats.CompletionRate - - return programsFacilitiesStats, nil -} - -func (db *Store) GetProgramsFacilityStats(args *QueryContext, timeFilter int) (ProgramsFacilitiesStats, error) { - var programsFacilityStats ProgramsFacilitiesStats - - var totals struct { - TotalPrograms int64 `json:"total_programs"` - TotalEnrollments int64 `json:"total_enrollments"` - } - - if err := db.WithContext(args.Ctx).Raw(` - SELECT COUNT(DISTINCT p.id) AS total_programs - FROM programs p - JOIN facilities_programs fp ON fp.program_id = p.id - JOIN program_class_cohorts pc ON pc.program_id = p.id AND pc.facility_id = fp.facility_id AND pc.status = 'Active' - JOIN program_class_enrollments pce ON pce.cohort_id = pc.id - WHERE fp.facility_id = ? AND p.is_active = true AND p.archived_at IS NULL - `, args.FacilityID).Scan(&totals.TotalPrograms).Error; err != nil { - return programsFacilityStats, newGetRecordsDBError(err, "total programs for facility") - } - - if err := db.WithContext(args.Ctx). - Model(&ProgramClassEnrollment{}). - Select("COUNT(CASE WHEN program_class_enrollments.enrolled_at IS NOT NULL THEN 1 END) AS total_enrollments"). - Joins("LEFT JOIN program_class_cohorts pc ON pc.id = program_class_enrollments.cohort_id"). - Where("pc.facility_id = ?", args.FacilityID). - Scan(&totals.TotalEnrollments).Error; err != nil { - return programsFacilityStats, newGetRecordsDBError(err, "total enrollments for facility") - } - - var rateStats struct { - AvgActiveProgramsPerFacility float64 `json:"avg_active_programs_per_facility"` - AttendanceRate float64 `json:"attendance_rate"` - CompletionRate float64 `json:"completion_rate"` - } - - completionTimeFilter := "" - attendanceTimeFilter := "" - if timeFilter > 0 { - cutoffDate := time.Now().AddDate(0, 0, -timeFilter).Format("2006-01-02") - completionTimeFilter = fmt.Sprintf("AND (pce.enrollment_ended_at >= '%s' OR pcev.created_at >= '%s')", cutoffDate, cutoffDate) - attendanceTimeFilter = completionTimeFilter - } - - partialAttendanceSQL := buildPartialAttendanceSQL(db.Name(), "pcea") - query := fmt.Sprintf(` - SELECT - COALESCE(COUNT(CASE WHEN pce.enrollment_status = 'Completed' AND pce.enrolled_at IS NOT NULL %s THEN 1 END) * 100.0 / - NULLIF(COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END), 0), 0) AS completion_rate, - COALESCE(SUM( - CASE - WHEN pcea.attendance_status = 'present' %s THEN 1 - WHEN pcea.attendance_status = 'partial' %s THEN %s - ELSE 0 - END - ) * 100.0 / - NULLIF(COUNT(CASE WHEN pcea.attendance_status IS NOT NULL AND pcea.attendance_status != '' %s THEN 1 END), 0), 0) AS attendance_rate - FROM program_class_enrollments pce - LEFT JOIN program_class_cohorts pc ON pc.id = pce.cohort_id - LEFT JOIN program_class_events pcev ON pcev.cohort_id = pc.id - LEFT JOIN program_class_event_attendance pcea ON pcea.event_id = pcev.id AND pcea.user_id = pce.user_id AND pcea.deleted_at IS NULL - WHERE pc.facility_id = ? AND pce.enrolled_at IS NOT NULL %s - `, completionTimeFilter, attendanceTimeFilter, attendanceTimeFilter, partialAttendanceSQL, attendanceTimeFilter, completionTimeFilter) - - if err := db.WithContext(args.Ctx).Raw(query, args.FacilityID).Scan(&rateStats).Error; err != nil { - return programsFacilityStats, newGetRecordsDBError(err, "facility completion and attendance rates") - } - - rateStats.AvgActiveProgramsPerFacility = float64(totals.TotalPrograms) - - programsFacilityStats.TotalPrograms = &totals.TotalPrograms - programsFacilityStats.TotalEnrollments = &totals.TotalEnrollments - programsFacilityStats.AvgActiveProgramsPerFacility = &rateStats.AvgActiveProgramsPerFacility - programsFacilityStats.AttendanceRate = &rateStats.AttendanceRate - programsFacilityStats.CompletionRate = &rateStats.CompletionRate - - return programsFacilityStats, nil -} - -func ParseOperatorAndValue(input string) (string, float64) { - ops := []string{">=", "<=", "!=", "=", ">", "<"} - for _, op := range ops { - if strings.HasPrefix(input, op) { - numString := strings.TrimSpace(input[len(op):]) - num, err := strconv.ParseFloat(numString, 64) - if err == nil { - return op, num - } - break - } - } - return "", 0 -} - -// applyNumericFilter mirrors applyRateFilter's fail-closed behavior: an -// unparseable operator/value (e.g. a malformed `filter_mr.total_classes` -// query param) leaves tx untouched instead of building a WHERE clause with a -// missing operator, which Postgres rejects as a syntax error. -func applyNumericFilter(tx *gorm.DB, column string, val string) *gorm.DB { - op, num := ParseOperatorAndValue(val) - if op == "" { - return tx - } - return tx.Where(fmt.Sprintf("%s %s ?", column, op), num) -} - -func applyRateFilter(tx *gorm.DB, rateExpr string, val string) *gorm.DB { - const floatTolerance = 0.005 - op, num := ParseOperatorAndValue(val) - switch op { - case "!=": - return tx.Where("ABS("+rateExpr+" - ?) > ?", num, floatTolerance) - case "=": - return tx.Where(rateExpr+" BETWEEN ? AND ?", num-floatTolerance, num+floatTolerance) - case ">=": - return tx.Where(rateExpr+" >= ?", num-floatTolerance) - case "<=": - return tx.Where(rateExpr+" <= ?", num+floatTolerance) - case ">": - return tx.Where(rateExpr+" > ?", num) - case "<": - return tx.Where(rateExpr+" < ?", num) - } - return tx -} - -func (db *Store) GetProgramsOverviewTable(args *QueryContext, timeFilter int, includeArchived bool, adminRole users.Role, filters map[string]string) ([]ProgramsOverviewTable, error) { - var programsTable []ProgramsOverviewTable - - baseQuery := db.WithContext(args.Ctx).Model(&Program{}) - - facilityScoped := adminRole == users.FacilityAdmin - var selectFields string - if facilityScoped { - selectFields = ` - programs.id AS program_id, - programs.name AS program_name, - programs.description AS description, - programs.archived_at AS archived_at, - mr.total_enrollments AS total_enrollments, - mr.total_active_enrollments AS total_active_enrollments, - mr.total_classes AS total_classes, - mr.total_active_classes AS total_active_classes, - mr.total_cancelled_classes AS total_cancelled_classes, - mr.total_capacity AS total_capacity, - mr.total_filled_seats AS total_filled_seats, - time_filtered_rates.completion_rate AS completion_rate, - time_filtered_rates.rate_eligible_completions AS rate_eligible_completions, - time_filtered_rates.rate_eligible_enrollments AS rate_eligible_enrollments, - time_filtered_rates.attendance_rate AS attendance_rate, - pt.program_types AS program_types, - pct.credit_types AS credit_types, - programs.funding_type AS funding_type, - programs.is_active AS status - ` - } else { - selectFields = ` - programs.id AS program_id, - programs.name AS program_name, - programs.description AS description, - programs.archived_at AS archived_at, - mr.total_active_facilities AS total_active_facilities, - mr.total_enrollments AS total_enrollments, - mr.total_active_enrollments AS total_active_enrollments, - mr.total_classes AS total_classes, - mr.total_active_classes AS total_active_classes, - mr.total_cancelled_classes AS total_cancelled_classes, - mr.total_capacity AS total_capacity, - mr.total_filled_seats AS total_filled_seats, - time_filtered_rates.completion_rate AS completion_rate, - time_filtered_rates.rate_eligible_completions AS rate_eligible_completions, - time_filtered_rates.rate_eligible_enrollments AS rate_eligible_enrollments, - time_filtered_rates.attendance_rate AS attendance_rate, - pt.program_types AS program_types, - pct.credit_types AS credit_types, - programs.funding_type AS funding_type, - programs.is_active AS status - ` - } - - var currentMetricsSubquery string - if facilityScoped { - currentMetricsSubquery = fmt.Sprintf(` - LEFT JOIN ( - SELECT - p.id as program_id, - COUNT(DISTINCT pce.id) AS total_enrollments, - COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) THEN pce.id END) AS total_active_enrollments, - COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) AND pc.status = 'Active' THEN pce.id END) AS total_filled_seats, - COUNT(DISTINCT CASE WHEN pc.status != 'Cancelled' THEN pc.id END) AS total_classes, - COUNT(DISTINCT CASE WHEN pc.status = 'Active' THEN pc.id END) AS total_active_classes, - COUNT(DISTINCT CASE WHEN pc.status = 'Cancelled' THEN pc.id END) AS total_cancelled_classes, - class_stats.total_capacity - FROM programs p - JOIN facilities_programs fp ON fp.program_id = p.id - LEFT JOIN program_class_cohorts pc ON pc.program_id = p.id AND pc.facility_id = %d - LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id - LEFT JOIN ( - SELECT pc2.program_id, COALESCE(SUM(CASE WHEN pc2.status = 'Active' THEN pc2.capacity ELSE 0 END), 0) AS total_capacity - FROM program_class_cohorts pc2 - GROUP BY pc2.program_id - ) AS class_stats ON class_stats.program_id = p.id - WHERE fp.facility_id = %d - GROUP BY p.id, class_stats.total_capacity - ) AS mr ON mr.program_id = programs.id - `, args.FacilityID, args.FacilityID) - } else { - currentMetricsSubquery = ` - LEFT JOIN ( - SELECT - p.id as program_id, - COUNT(DISTINCT CASE WHEN p.is_active = true AND p.archived_at IS NULL THEN fp.facility_id END) AS total_active_facilities, - COUNT(DISTINCT pce.id) AS total_enrollments, - COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) THEN pce.id END) AS total_active_enrollments, - COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) AND pc.status = 'Active' THEN pce.id END) AS total_filled_seats, - COUNT(DISTINCT CASE WHEN pc.status != 'Cancelled' THEN pc.id END) AS total_classes, - COUNT(DISTINCT CASE WHEN pc.status = 'Active' THEN pc.id END) AS total_active_classes, - COUNT(DISTINCT CASE WHEN pc.status = 'Cancelled' THEN pc.id END) AS total_cancelled_classes, - class_stats.total_capacity - FROM programs p - LEFT JOIN facilities_programs fp ON fp.program_id = p.id - LEFT JOIN program_class_cohorts pc ON pc.program_id = p.id - LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id - LEFT JOIN ( - SELECT pc2.program_id, COALESCE(SUM(CASE WHEN pc2.status = 'Active' THEN pc2.capacity ELSE 0 END), 0) AS total_capacity - FROM program_class_cohorts pc2 - GROUP BY pc2.program_id - ) AS class_stats ON class_stats.program_id = p.id - GROUP BY p.id, class_stats.total_capacity - ) AS mr ON mr.program_id = programs.id - ` - } - - timeFilterCondition := "" - if timeFilter > 0 { - cutoffDate := time.Now().AddDate(0, 0, -timeFilter) - timeFilterCondition = fmt.Sprintf(` - AND (pce.enrollment_ended_at >= '%s' OR pcev.created_at >= '%s') - `, cutoffDate.Format("2006-01-02"), cutoffDate.Format("2006-01-02")) - } - - var facilityFilterForRates string - if facilityScoped { - facilityFilterForRates = fmt.Sprintf("AND pc.facility_id = %d", args.FacilityID) - } - - partialAttendanceSQL := buildPartialAttendanceSQL(db.Name(), "pcea") - - // DISTINCT on pce.id: the pcev/pcea joins below fan out one enrollment into many - // rows (one per event/attendance record), so a plain COUNT double-counts enrollments - // with more recorded attendance and skews the rate. - // Both halves are also projected as their own columns so a client aggregating - // across programs can weight by the real denominator instead of guessing one. - rateCompletionsSQL := `COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Completed' AND pce.enrollment_ended_at IS NOT NULL AND ` + rateEligibleClassStatusSQL + ` ` + timeFilterCondition + ` THEN pce.id END)` - rateEligibleSQL := `COUNT(DISTINCT CASE WHEN pce.enrollment_status IN (` + completedOutcomeStatusSQLList() + `) AND pce.enrollment_ended_at IS NOT NULL AND ` + rateEligibleClassStatusSQL + ` THEN pce.id END)` - - timeFilteredRatesSubquery := ` - LEFT JOIN ( - SELECT - p.id as program_id, - ` + rateCompletionsSQL + ` * 100.0 / NULLIF(` + rateEligibleSQL + `, 0) AS completion_rate, - ` + rateCompletionsSQL + ` AS rate_eligible_completions, - ` + rateEligibleSQL + ` AS rate_eligible_enrollments, - SUM( - CASE - WHEN pcea.attendance_status = 'present' ` + timeFilterCondition + ` THEN 1 - WHEN pcea.attendance_status = 'partial' ` + timeFilterCondition + ` THEN ` + partialAttendanceSQL + ` - ELSE 0 - END - ) * 100.0 / - NULLIF(COUNT(CASE WHEN pcea.attendance_status IS NOT NULL AND pcea.attendance_status != '' ` + timeFilterCondition + ` THEN 1 END), 0) AS attendance_rate - FROM programs p - LEFT JOIN program_class_cohorts pc ON pc.program_id = p.id ` + facilityFilterForRates + ` - LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id - LEFT JOIN program_class_events pcev ON pcev.cohort_id = pc.id - LEFT JOIN program_class_event_attendance pcea ON pcea.event_id = pcev.id AND pcea.user_id = pce.user_id AND pcea.deleted_at IS NULL - WHERE 1=1 ` + timeFilterCondition + ` - GROUP BY p.id - ) AS time_filtered_rates ON time_filtered_rates.program_id = programs.id - ` - - tx := baseQuery.Select(selectFields). - Joins(currentMetricsSubquery). - Joins(timeFilteredRatesSubquery). - Joins(`LEFT JOIN ( - SELECT program_id, STRING_AGG(DISTINCT program_type::text, ',') AS program_types - FROM program_types - GROUP BY program_id - ) AS pt ON pt.program_id = programs.id`). - Joins(`LEFT JOIN ( - SELECT program_id, STRING_AGG(DISTINCT credit_type::text, ',') AS credit_types - FROM program_credit_types - GROUP BY program_id - ) AS pct ON pct.program_id = programs.id`) - - if facilityScoped { - tx = tx.Joins("JOIN facilities_programs fp ON fp.program_id = programs.id").Where("fp.facility_id = ?", args.FacilityID) - } - - if !includeArchived { - tx = tx.Where("programs.archived_at IS NULL") - } - - if len(args.Tags) > 0 { - tx = tx.Where("programs.id IN (SELECT program_id FROM program_types WHERE program_type IN (?))", args.Tags) - } - - for col, val := range filters { - switch col { - case "programs.name": - tx = tx.Where("programs.name ILIKE ?", "%"+val+"%") - case "mr.total_enrollments": - tx = applyNumericFilter(tx, "mr.total_enrollments", val) - case "mr.total_active_enrollments": - tx = applyNumericFilter(tx, "mr.total_active_enrollments", val) - case "mr.total_classes": - tx = applyNumericFilter(tx, "mr.total_classes", val) - case "mr.total_active_classes": - tx = applyNumericFilter(tx, "mr.total_active_classes", val) - case "capacity_utilization": - tx = applyRateFilter(tx, "(mr.total_filled_seats * 100.0 / NULLIF(mr.total_capacity, 0))", val) - case "mr.total_active_facilities": - if !facilityScoped { - tx = applyNumericFilter(tx, "mr.total_active_facilities", val) - } - case "completion_rate": - tx = applyRateFilter(tx, "time_filtered_rates.completion_rate", val) - case "attendance_rate": - tx = applyRateFilter(tx, "time_filtered_rates.attendance_rate", val) - case "pt.program_types": - tx = tx.Where("pt.program_types ~* ?", val) - case "pct.credit_types": - tx = tx.Where("pct.credit_types ~* ?", val) - case "programs.funding_type": - tx = tx.Where("programs.funding_type IN (?)", strings.Split(val, "|")) - case "programs.is_active": - statuses := strings.Split(val, "|") - conditions := make([]string, 0, len(statuses)) - for _, status := range statuses { - switch status { - case "Available": - conditions = append(conditions, "programs.is_active = true AND programs.archived_at IS NULL") - case "Archived": - conditions = append(conditions, "programs.archived_at IS NOT NULL") - case "Inactive": - conditions = append(conditions, "programs.is_active = false AND programs.archived_at IS NULL") - } - } - if len(conditions) > 0 { - whereStatement := "(" + strings.Join(conditions, " OR ") + ")" - tx = tx.Where(whereStatement) - } - case "facility_id": - tx = tx.Joins("JOIN facilities_programs fp ON fp.program_id = programs.id").Where("fp.facility_id = ?", val) - } - } - - if args.Search != "" { - tx = tx.Where("LOWER(programs.name) LIKE ? OR LOWER(programs.description) LIKE ?", args.SearchQuery(), args.SearchQuery()) - } - - if err := tx.Count(&args.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "programs table count") - } - - if args.OrderBy != "" && args.Order != "" { - tx = tx.Order(args.OrderBy + " " + args.Order) - } else { - tx = tx.Order(args.OrderClause("programs.created_at desc")) - } - - if !args.All { - tx = tx.Limit(args.PerPage).Offset(args.CalcOffset()) - } - if err := tx.Scan(&programsTable).Error; err != nil { - return programsTable, newGetRecordsDBError(err, "programs table") - } - - return programsTable, nil -} - -func (db *Store) GetProgramCreatedAtAndBy(id int, args *QueryContext) (ActivityHistoryResponse, error) { - var programDetails ActivityHistoryResponse - if err := db.WithContext(args.Ctx).Table("programs p"). - Select("p.created_at, u.username as admin_username"). - Joins("join users u on u.id = p.create_user_id"). - Where("p.id = ?", id). - Scan(&programDetails).Error; err != nil { - return programDetails, newNotFoundDBError(err, "programs") - } - return programDetails, nil -} - -func (db *Store) GetProgramsCSVData(args *QueryContext) ([]ProgramCSVData, error) { - var programCSVData []ProgramCSVData - statuses := CompletedOutcomeStatuses - - partialAttendanceSQL := buildPartialAttendanceSQL(db.Name(), "pca") - tx := db.WithContext(args.Ctx).Table("programs p"). - Joins("JOIN program_class_cohorts pc ON pc.program_id = p.id"). - Joins("JOIN program_classes cl ON cl.id = pc.class_id"). - Joins("JOIN program_class_enrollments pe ON pe.cohort_id = pc.id"). - Joins("JOIN users u on u.id = pe.user_id"). - Joins("JOIN facilities f ON f.id = u.facility_id"). - Joins("LEFT JOIN class_completions c on c.cohort_id = pc.id and c.user_id = u.id"). - Joins("LEFT JOIN program_class_events pce on pce.cohort_id = pc.id"). - Joins("LEFT JOIN users iu ON iu.id = pce.instructor_id"). - Joins("LEFT JOIN program_class_event_attendance pca ON pca.event_id = pce.id AND pca.user_id = u.id AND pca.deleted_at IS NULL"). - Select(` - f.name AS facility_name, - p.name AS program_name, - cl.name AS class_name, - COALESCE(iu.name_first || ' ' || iu.name_last, '') AS instructor_name, - u.id as unlock_ed_id, - u.doc_id AS resident_id, - u.name_last AS name_last, - u.name_first AS name_first, - pe.created_at as enrollment_date, - COALESCE( - CASE - WHEN c.id IS NOT NULL THEN c.created_at - WHEN pe.enrollment_status IN (?) THEN pe.updated_at - END, - pc.end_dt - ) as end_date, - pe.enrollment_status AS end_status, - CASE - WHEN COUNT(CASE WHEN pca.attendance_status IS NOT NULL AND pca.attendance_status != '' THEN 1 END) = 0 THEN 0 - ELSE - COALESCE( - SUM( - CASE - WHEN pca.attendance_status = 'present' THEN 1 - WHEN pca.attendance_status = 'partial' THEN `+partialAttendanceSQL+` - ELSE 0 - END - ) * 100.0 / - NULLIF(COUNT(CASE WHEN pca.attendance_status IS NOT NULL AND pca.attendance_status != '' THEN 1 END), 0), - 0 - ) - END AS attendance_percentage`, statuses). - Where(` - ( - c.id IS NOT NULL - OR pe.enrollment_status IN (?) - ) - `, statuses). - Group("u.id, u.doc_id, f.name, p.name, cl.name, iu.name_first, iu.name_last, pe.created_at, pe.enrollment_status, pe.updated_at, c.id, c.created_at, pc.end_dt"). - Order("f.name ASC, p.name ASC, cl.name ASC, end_date DESC") - - if !args.All { - tx = tx.Where("f.id = ?", args.FacilityID) - } - - if err := tx.Scan(&programCSVData).Error; err != nil { - return nil, newGetRecordsDBError(err, "programs CSV data") - } - return programCSVData, nil -} - -func buildPartialAttendanceSQL(dialect, alias string) string { - minutesCast := "COALESCE(%s.minutes_attended, %s.scheduled_minutes, 0)::numeric" - if dialect == "sqlite" { - minutesCast = "CAST(COALESCE(%s.minutes_attended, %s.scheduled_minutes, 0) AS REAL)" - } - minutesCast = fmt.Sprintf(minutesCast, alias, alias) - ratioExpr := fmt.Sprintf("%s / NULLIF(COALESCE(%s.scheduled_minutes, %s.minutes_attended, 0), 0)", minutesCast, alias, alias) - partialAttendanceSQL := fmt.Sprintf("CASE WHEN %s < 1 THEN %s ELSE 1 END", ratioExpr, ratioExpr) - return partialAttendanceSQL -} - -// completedOutcomeStatusSQLList renders CompletedOutcomeStatuses as a quoted, -// comma-separated SQL list for inlining into a raw `IN (...)` clause. -func completedOutcomeStatusSQLList() string { - quoted := make([]string, len(CompletedOutcomeStatuses)) - for i, status := range CompletedOutcomeStatuses { - quoted[i] = "'" + string(status) + "'" - } - return strings.Join(quoted, ", ") -} - -// rateEligibleClassStatusSQL excludes classes that haven't started (Scheduled) -// or never ran (Cancelled) from completion/attendance rate calculations, so a -// class with no real data doesn't produce a misleading rate. Shared by every -// completion-rate query so the eligibility rule can't drift between them. -const rateEligibleClassStatusSQL = "pc.status NOT IN ('Scheduled', 'Cancelled')" diff --git a/backend/src/modules/programs/reports.go b/backend/src/modules/programs/reports.go deleted file mode 100644 index 52b8857ce..000000000 --- a/backend/src/modules/programs/reports.go +++ /dev/null @@ -1,738 +0,0 @@ -package programs - -import ( - "fmt" - "math" - "strconv" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - - "github.com/xuri/excelize/v2" -) - -type ReportType string - -const ( - AttendanceReport ReportType = "attendance" - ProgramOutcomesReport ReportType = "program_outcomes" - FacilityComparisonReport ReportType = "facility_comparison" - ClassRosterReport ReportType = "class_roster" - ResidentProfileReport ReportType = "resident_profile" -) - -type ReportFormat string - -const ( - FormatCSV ReportFormat = "csv" - FormatPDF ReportFormat = "pdf" - FormatExcel ReportFormat = "excel" -) - -type ReportGenerateRequest struct { - Type ReportType `json:"type" validate:"required,oneof=attendance program_outcomes facility_comparison class_roster resident_profile"` - Format ReportFormat `json:"format" validate:"required,oneof=csv pdf excel"` - StartDate time.Time `json:"start_date" validate:"required"` - EndDate time.Time `json:"end_date" validate:"required,gtefield=StartDate"` - - FacilityID *uint `json:"facility_id"` - FacilityIDs []uint `json:"facility_ids" validate:"omitempty,dive,min=1"` - ProgramID *uint `json:"program_id"` - ProgramIDs []uint `json:"program_ids" validate:"omitempty,dive,min=1"` - CohortID *uint `json:"cohort_id"` - UserID *uint `json:"user_id"` - ClassStatus *string `json:"class_status"` - ProgramTypes []ProgType `json:"program_types"` - FundingTypes []FundingType `json:"funding_types"` - - IncludeClassBreakdown bool `json:"include_class_breakdown"` - IncludeInactive bool `json:"include_inactive"` - EnrollmentStatuses []string `json:"enrollment_statuses"` - IncludeIncompleteReason bool `json:"include_incomplete_reason"` - IncludeAttendanceRate bool `json:"include_attendance_rate"` - IncludeEnrollmentDates bool `json:"include_enrollment_dates"` -} - -type AttendanceReportRow struct { - FacilityName string - ProgramName string - ClassName string - Date time.Time - StudentLastName string - StudentFirstName string - DocID string - AttendanceStatus Attendance - SeatTimeMinutes *int - AbsenceReason *string - RecordedBy *string -} - -type ProgramOutcomesReportRow struct { - ProgramName string - IsActive bool - ProgramType string - FacilitiesActive int - TotalClasses int - ActiveEnrollments int - TotalEnrollments int - TotalCapacity int - Utilization int -} - -type FacilityComparisonReportRow struct { - FacilityName string - TotalPrograms int - ActivePrograms int - TotalEnrollments int - ActiveEnrollments int - CompletionRate float64 - AttendanceRate float64 - TopProgramType string - TotalCreditHours float64 - CertificatesEarned int - LastActivityDate *time.Time -} - -type AttendanceReportData struct { - Data []AttendanceReportRow -} - -type ProgramClassBreakdownRow struct { - ClassName string - Status string - CreditHours *int64 - Capacity int - ActiveEnrollments int - RangeEnrollments int -} - -type ProgramOutcomesReportData struct { - Data []ProgramOutcomesReportRow - Classes []ProgramClassBreakdownRow - IncludeStatus bool -} - -func programStatusLabel(isActive bool) string { - if isActive { - return "Active" - } - return "Inactive" -} - -type FacilityComparisonReportData struct { - Data []FacilityComparisonReportRow -} - -func (r AttendanceReportData) Len() int { - return len(r.Data) -} - -func (r AttendanceReportData) ToCSV() ([][]string, error) { - csvData := [][]string{ - {"Date", "Program Name", "Class Name", "Facility", "Resident Name", "DOC ID", "Attendance Status", "Note"}, - } - - for _, row := range r.Data { - csvData = append(csvData, []string{ - row.Date.Format("2006-01-02"), - row.ProgramName, - row.ClassName, - row.FacilityName, - formatResidentName(row.StudentLastName, row.StudentFirstName), - row.DocID, - row.AttendanceStatus.HumanReadable(), - FormatNullableString(row.AbsenceReason), - }) - } - - return csvData, nil -} - -func (r ProgramOutcomesReportData) Len() int { - return len(r.Data) -} - -func (r ProgramOutcomesReportData) headers() []string { - headers := []string{"Program Name", "Program Type", "Facilities Active", "Total Classes", "Currently Enrolled", "Enrolled in Range", "Total Capacity", "Utilization %"} - // Status is appended (never inserted) so the fixed column positions the PDF - // template maps stay stable; the extra trailing column is hidden in the PDF - // unless requested. - if r.IncludeStatus { - headers = append(headers, "Status") - } - return headers -} - -func (r ProgramOutcomesReportData) rowValues(row ProgramOutcomesReportRow) []string { - values := []string{ - row.ProgramName, - humanizeProgramTypes(row.ProgramType), - strconv.Itoa(row.FacilitiesActive), - strconv.Itoa(row.TotalClasses), - strconv.Itoa(row.ActiveEnrollments), - strconv.Itoa(row.TotalEnrollments), - strconv.Itoa(row.TotalCapacity), - strconv.Itoa(row.Utilization), - } - if r.IncludeStatus { - values = append(values, programStatusLabel(row.IsActive)) - } - return values -} - -func (r ProgramOutcomesReportData) rowCells(row ProgramOutcomesReportRow) []any { - cells := []any{ - row.ProgramName, - humanizeProgramTypes(row.ProgramType), - row.FacilitiesActive, - row.TotalClasses, - row.ActiveEnrollments, - row.TotalEnrollments, - row.TotalCapacity, - row.Utilization, - } - if r.IncludeStatus { - cells = append(cells, programStatusLabel(row.IsActive)) - } - return cells -} - -func (r ProgramOutcomesReportData) ToCSV() ([][]string, error) { - csvData := [][]string{r.headers()} - for _, row := range r.Data { - csvData = append(csvData, r.rowValues(row)) - } - - return csvData, nil -} - -func humanizeProgramTypes(agg string) string { - if agg == "" || agg == "N/A" { - return agg - } - parts := strings.Split(agg, ",") - for i, p := range parts { - parts[i] = HumanReadableProgType(strings.TrimSpace(p)) - } - return strings.Join(parts, ", ") -} - -func (r FacilityComparisonReportData) Len() int { - return len(r.Data) -} - -func (r FacilityComparisonReportData) ToCSV() ([][]string, error) { - csvData := [][]string{ - {"Facility", "Total Programs", "Active Programs", "Total Enrollments", "Active Enrollments", "Completion Rate (%)", "Attendance Rate (%)", "Top Program Type", "Total Credit Hours", "Certificates Earned", "Last Activity Date"}, - } - - for _, row := range r.Data { - lastActivity := "" - if row.LastActivityDate != nil { - lastActivity = row.LastActivityDate.Format("2006-01-02") - } - - csvData = append(csvData, []string{ - row.FacilityName, - strconv.Itoa(row.TotalPrograms), - strconv.Itoa(row.ActivePrograms), - strconv.Itoa(row.TotalEnrollments), - strconv.Itoa(row.ActiveEnrollments), - strconv.FormatFloat(row.CompletionRate, 'f', 2, 64), - strconv.FormatFloat(row.AttendanceRate, 'f', 2, 64), - HumanReadableProgType(row.TopProgramType), - strconv.FormatFloat(row.TotalCreditHours, 'f', 2, 64), - strconv.Itoa(row.CertificatesEarned), - lastActivity, - }) - } - - return csvData, nil -} - -//nolint:errcheck // Excel cell setting errors are unlikely and checked at sheet creation -func (r AttendanceReportData) ToExcel() (*excelize.File, error) { - f := excelize.NewFile() - sheetName := "Attendance Report" - index, err := f.NewSheet(sheetName) - if err != nil { - return nil, fmt.Errorf("failed to create Excel sheet: %w", err) - } - f.SetActiveSheet(index) - - headers := []string{"Date", "Program Name", "Class Name", "Facility", "Resident Name", "DOC ID", "Attendance Status", "Note"} - for i, header := range headers { - cell := fmt.Sprintf("%s1", excelColumnName(i)) - f.SetCellValue(sheetName, cell, header) - } - - for i, row := range r.Data { - rowNum := i + 2 - f.SetCellValue(sheetName, fmt.Sprintf("A%d", rowNum), row.Date.Format("2006-01-02")) - f.SetCellValue(sheetName, fmt.Sprintf("B%d", rowNum), row.ProgramName) - f.SetCellValue(sheetName, fmt.Sprintf("C%d", rowNum), row.ClassName) - f.SetCellValue(sheetName, fmt.Sprintf("D%d", rowNum), row.FacilityName) - f.SetCellValue(sheetName, fmt.Sprintf("E%d", rowNum), formatResidentName(row.StudentLastName, row.StudentFirstName)) - f.SetCellValue(sheetName, fmt.Sprintf("F%d", rowNum), row.DocID) - f.SetCellValue(sheetName, fmt.Sprintf("G%d", rowNum), row.AttendanceStatus.HumanReadable()) - f.SetCellValue(sheetName, fmt.Sprintf("H%d", rowNum), FormatNullableString(row.AbsenceReason)) - } - - return f, nil -} - -//nolint:errcheck // Excel cell setting errors are unlikely and checked at sheet creation -func (r ProgramOutcomesReportData) ToExcel() (*excelize.File, error) { - f := excelize.NewFile() - sheetName := "Program Outcomes" - index, err := f.NewSheet(sheetName) - if err != nil { - return nil, fmt.Errorf("failed to create Excel sheet: %w", err) - } - f.SetActiveSheet(index) - - for i, header := range r.headers() { - cell := fmt.Sprintf("%s1", excelColumnName(i)) - f.SetCellValue(sheetName, cell, header) - } - - for i, row := range r.Data { - rowNum := i + 2 - for j, val := range r.rowCells(row) { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", excelColumnName(j), rowNum), val) - } - } - - if len(r.Classes) > 0 { - titleRow := len(r.Data) + 3 // one blank spacer row after the program rows - f.SetCellValue(sheetName, fmt.Sprintf("A%d", titleRow), "Class Breakdown") - - classHeaders := []string{"Class", "Status", "Credit Hours", "Capacity", "Currently Enrolled", "Enrolled in Range", "Utilization %"} - headerRow := titleRow + 1 - for i, h := range classHeaders { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", excelColumnName(i), headerRow), h) - } - - for k, cr := range r.subRows() { - rowNum := headerRow + 1 + k - for j, val := range cr { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", excelColumnName(j), rowNum), val) - } - } - } - - return f, nil -} - -//nolint:errcheck // Excel cell setting errors are unlikely and checked at sheet creation -func (r FacilityComparisonReportData) ToExcel() (*excelize.File, error) { - f := excelize.NewFile() - sheetName := "Facility Comparison" - index, err := f.NewSheet(sheetName) - if err != nil { - return nil, fmt.Errorf("failed to create Excel sheet: %w", err) - } - f.SetActiveSheet(index) - - headers := []string{"Facility", "Total Programs", "Active Programs", "Total Enrollments", "Active Enrollments", "Completion Rate (%)", "Attendance Rate (%)", "Top Program Type", "Total Credit Hours", "Certificates Earned", "Last Activity Date"} - for i, header := range headers { - cell := fmt.Sprintf("%s1", excelColumnName(i)) - f.SetCellValue(sheetName, cell, header) - } - - for i, row := range r.Data { - rowNum := i + 2 - lastActivity := "" - if row.LastActivityDate != nil { - lastActivity = row.LastActivityDate.Format("2006-01-02") - } - - f.SetCellValue(sheetName, fmt.Sprintf("A%d", rowNum), row.FacilityName) - f.SetCellValue(sheetName, fmt.Sprintf("B%d", rowNum), row.TotalPrograms) - f.SetCellValue(sheetName, fmt.Sprintf("C%d", rowNum), row.ActivePrograms) - f.SetCellValue(sheetName, fmt.Sprintf("D%d", rowNum), row.TotalEnrollments) - f.SetCellValue(sheetName, fmt.Sprintf("E%d", rowNum), row.ActiveEnrollments) - f.SetCellValue(sheetName, fmt.Sprintf("F%d", rowNum), fmt.Sprintf("%.2f", row.CompletionRate)) - f.SetCellValue(sheetName, fmt.Sprintf("G%d", rowNum), fmt.Sprintf("%.2f", row.AttendanceRate)) - f.SetCellValue(sheetName, fmt.Sprintf("H%d", rowNum), HumanReadableProgType(row.TopProgramType)) - f.SetCellValue(sheetName, fmt.Sprintf("I%d", rowNum), fmt.Sprintf("%.2f", row.TotalCreditHours)) - f.SetCellValue(sheetName, fmt.Sprintf("J%d", rowNum), row.CertificatesEarned) - f.SetCellValue(sheetName, fmt.Sprintf("K%d", rowNum), lastActivity) - } - - return f, nil -} - -func (r AttendanceReportData) ToPDF() (module.Report, error) { - tableData := make([][]string, len(r.Data)) - for i, row := range r.Data { - tableData[i] = []string{ - row.Date.Format("2006-01-02"), - row.ProgramName, - row.ClassName, - row.FacilityName, - formatResidentName(row.StudentLastName, row.StudentFirstName), - row.DocID, - row.AttendanceStatus.HumanReadable(), - FormatNullableString(row.AbsenceReason), - } - } - - return module.Report{ - Title: "Attendance Records", - Rows: tableData, - }, nil -} - -func (r ProgramOutcomesReportData) ToPDF() (module.Report, error) { - tableData := make([][]string, len(r.Data)) - for i, row := range r.Data { - tableData[i] = r.rowValues(row) - } - - return module.Report{ - Title: "Program Export", - Rows: tableData, - SubRows: r.subRows(), - // Toggles the trailing Status column in the PDF template. - Params: map[string]string{ - "ShowStatus": strconv.FormatBool(r.IncludeStatus), - }, - }, nil -} - -func (r ProgramOutcomesReportData) subRows() [][]string { - rows := make([][]string, len(r.Classes)) - for i, c := range r.Classes { - util := 0 - if c.Capacity > 0 { - util = int(math.Round(100.0 * float64(c.ActiveEnrollments) / float64(c.Capacity))) - } - creditHours := "" - if c.CreditHours != nil { - creditHours = strconv.FormatInt(*c.CreditHours, 10) - } - rows[i] = []string{ - c.ClassName, - c.Status, - creditHours, - strconv.Itoa(c.Capacity), - strconv.Itoa(c.ActiveEnrollments), - strconv.Itoa(c.RangeEnrollments), - strconv.Itoa(util) + "%", - } - } - return rows -} - -func (r FacilityComparisonReportData) ToPDF() (module.Report, error) { - tableData := make([][]string, len(r.Data)) - for i, row := range r.Data { - lastActivity := "" - if row.LastActivityDate != nil { - lastActivity = row.LastActivityDate.Format("2006-01-02") - } - tableData[i] = []string{ - row.FacilityName, - fmt.Sprintf("%d", row.TotalPrograms), - fmt.Sprintf("%d", row.ActivePrograms), - fmt.Sprintf("%d", row.TotalEnrollments), - fmt.Sprintf("%d", row.ActiveEnrollments), - fmt.Sprintf("%.1f", row.CompletionRate), - fmt.Sprintf("%.1f", row.AttendanceRate), - HumanReadableProgType(row.TopProgramType), - fmt.Sprintf("%.1f", row.TotalCreditHours), - fmt.Sprintf("%d", row.CertificatesEarned), - lastActivity, - } - } - - return module.Report{ - Title: "Facility Comparison", - Rows: tableData, - }, nil -} - -func FormatNullableInt(val *int) string { - if val == nil { - return "" - } - return strconv.Itoa(*val) -} - -func FormatNullableString(val *string) string { - if val == nil { - return "" - } - return *val -} - -func excelColumnName(col int) string { - name := "" - for col >= 0 { - name = string(rune('A'+(col%26))) + name - col = col/26 - 1 - } - return name -} - -func HumanReadableProgType(s string) string { - return ProgType(s).HumanReadable() -} - -func HumanReadableFundingType(s string) string { - return FundingType(s).HumanReadable() -} - -// ---- Class Roster report ---- - -type ClassRosterReportRow struct { - NameLast string - NameFirst string - DocID string - EnrollmentStatus string - EnrolledAt *time.Time - EndedAt *time.Time - SessionsAttended int - TotalSessions int -} - -type ClassRosterReportData struct { - Data []ClassRosterReportRow - IncludeIncompleteReason bool - IncludeAttendanceRate bool - IncludeEnrollmentDates bool -} - -func (r ClassRosterReportData) Len() int { - return len(r.Data) -} - -func (r ClassRosterReportData) headers() []string { - headers := []string{"Resident Name", "DOC ID", "Enrollment Status"} - if r.IncludeIncompleteReason { - headers = append(headers, "Incomplete Reason") - } - if r.IncludeAttendanceRate { - headers = append(headers, "Avg Attendance Rate") - } - if r.IncludeEnrollmentDates { - headers = append(headers, "Enrolled At", "Ended At") - } - return headers -} - -func (r ClassRosterReportData) rowValues(row ClassRosterReportRow) []string { - status, reason := splitEnrollmentStatus(row.EnrollmentStatus) - values := []string{ - formatResidentName(row.NameLast, row.NameFirst), - row.DocID, - status, - } - if r.IncludeIncompleteReason { - values = append(values, reason) - } - if r.IncludeAttendanceRate { - values = append(values, formatAttendanceRate(row.SessionsAttended, row.TotalSessions)) - } - if r.IncludeEnrollmentDates { - values = append(values, formatReportDate(row.EnrolledAt), formatReportDate(row.EndedAt)) - } - return values -} - -func (r ClassRosterReportData) ToCSV() ([][]string, error) { - csvData := [][]string{r.headers()} - for _, row := range r.Data { - csvData = append(csvData, r.rowValues(row)) - } - return csvData, nil -} - -//nolint:errcheck // Excel cell setting errors are unlikely and checked at sheet creation -func (r ClassRosterReportData) ToExcel() (*excelize.File, error) { - f := excelize.NewFile() - sheetName := "Class Roster" - index, err := f.NewSheet(sheetName) - if err != nil { - return nil, fmt.Errorf("failed to create Excel sheet: %w", err) - } - f.SetActiveSheet(index) - - for i, header := range r.headers() { - f.SetCellValue(sheetName, fmt.Sprintf("%s1", excelColumnName(i)), header) - } - for i, row := range r.Data { - for j, val := range r.rowValues(row) { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", excelColumnName(j), i+2), val) - } - } - - return f, nil -} - -func (r ClassRosterReportData) ToPDF() (module.Report, error) { - tableData := make([][]string, len(r.Data)) - for i, row := range r.Data { - status, reason := splitEnrollmentStatus(row.EnrollmentStatus) - tableData[i] = []string{ - formatResidentName(row.NameLast, row.NameFirst), - row.DocID, - status, - reason, - formatAttendanceRate(row.SessionsAttended, row.TotalSessions), - formatReportDate(row.EnrolledAt), - formatReportDate(row.EndedAt), - } - } - - return module.Report{ - Title: "Class Roster", - Rows: tableData, - // use these to hide/show columns in report - Params: map[string]string{ - "ShowIncompleteReason": strconv.FormatBool(r.IncludeIncompleteReason), - "ShowAttendanceRate": strconv.FormatBool(r.IncludeAttendanceRate), - "ShowEnrollmentDates": strconv.FormatBool(r.IncludeEnrollmentDates), - }, - }, nil -} - -// ---- Resident Profile report ---- - -type ResidentProfileReportRow struct { - NameLast string - NameFirst string - DocID string - FacilityName string - ProgramName string - ClassName string - EnrollmentStatus string - EnrolledAt *time.Time - EndedAt *time.Time - SessionsAttended int - TotalSessions int -} - -type ResidentProfileReportData struct { - Data []ResidentProfileReportRow -} - -var residentProfileHeaders = []string{ - "Resident Name", "DOC ID", "Facility", "Program Name", "Class Name", - "Enrollment Status", "Enrolled Date", "End Date", "Sessions Attended", - "Total Sessions", "Attendance Rate", "Completion Status", -} - -func (r ResidentProfileReportData) Len() int { - return len(r.Data) -} - -func (r ResidentProfileReportData) rowValues(row ResidentProfileReportRow) []string { - return []string{ - formatResidentName(row.NameLast, row.NameFirst), - row.DocID, - row.FacilityName, - row.ProgramName, - row.ClassName, - enrollmentStatusDisplay(row.EnrollmentStatus), - formatReportDate(row.EnrolledAt), - formatReportDate(row.EndedAt), - strconv.Itoa(row.SessionsAttended), - strconv.Itoa(row.TotalSessions), - formatAttendanceRate(row.SessionsAttended, row.TotalSessions), - enrollmentCompletionStatus(row.EnrollmentStatus), - } -} - -func (r ResidentProfileReportData) ToCSV() ([][]string, error) { - csvData := [][]string{residentProfileHeaders} - for _, row := range r.Data { - csvData = append(csvData, r.rowValues(row)) - } - return csvData, nil -} - -//nolint:errcheck // Excel cell setting errors are unlikely and checked at sheet creation -func (r ResidentProfileReportData) ToExcel() (*excelize.File, error) { - f := excelize.NewFile() - sheetName := "Resident Profile" - index, err := f.NewSheet(sheetName) - if err != nil { - return nil, fmt.Errorf("failed to create Excel sheet: %w", err) - } - f.SetActiveSheet(index) - - for i, header := range residentProfileHeaders { - f.SetCellValue(sheetName, fmt.Sprintf("%s1", excelColumnName(i)), header) - } - for i, row := range r.Data { - for j, val := range r.rowValues(row) { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", excelColumnName(j), i+2), val) - } - } - - return f, nil -} - -func (r ResidentProfileReportData) ToPDF() (module.Report, error) { - tableData := make([][]string, len(r.Data)) - for i, row := range r.Data { - tableData[i] = r.rowValues(row) - } - - return module.Report{ - Title: "Resident Profile", - Rows: tableData, - }, nil -} - -// ---- shared enrollment/report helpers ---- -func formatResidentName(last, first string) string { - name := strings.TrimSpace(fmt.Sprintf("%s, %s", last, first)) - return strings.Trim(name, ", ") -} - -func formatReportDate(t *time.Time) string { - if t == nil { - return "" - } - return t.Format("2006-01-02") -} - -func formatAttendanceRate(attended, total int) string { - if total <= 0 { - return "0.0%" - } - return fmt.Sprintf("%.1f%%", 100.0*float64(attended)/float64(total)) -} - -func splitEnrollmentStatus(status string) (display string, reason string) { - if reason, ok := strings.CutPrefix(status, "Incomplete:"); ok { - return "Incomplete", strings.TrimSpace(reason) - } - return status, "" -} - -func enrollmentStatusDisplay(status string) string { - if _, reason := splitEnrollmentStatus(status); reason != "" { - return reason - } - return status -} - -func enrollmentCompletionStatus(status string) string { - switch { - case status == string(EnrollmentCompleted): - return "Completed" - case status == string(Enrolled): - return "In Progress" - case status == string(EnrollmentCancelled): - return "Cancelled" - case strings.HasPrefix(status, "Incomplete:"): - return "Incomplete" - default: - return status - } -} diff --git a/backend/src/modules/programs/reports_handler.go b/backend/src/modules/programs/reports_handler.go deleted file mode 100644 index 6bcf92fad..000000000 --- a/backend/src/modules/programs/reports_handler.go +++ /dev/null @@ -1,590 +0,0 @@ -package programs - -import ( - "context" - "encoding/csv" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - - "github.com/UnlockedLabs/unlocked-contracts/identity" - "github.com/UnlockedLabs/unlocked-contracts/users" - "github.com/sirupsen/logrus" - "github.com/xuri/excelize/v2" -) - -const ( - maxFacilitiesInList = 50 -) - -func (srv *server) handleGenerateReport(w http.ResponseWriter, r *http.Request, log sLog) error { - claims := claimsFrom(r) - - var req ReportGenerateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return newJSONReqBodyServiceError(err) - } - - if !isValidReportType(req.Type) { - return newBadRequestServiceError(errors.New("invalid report type"), - "invalid report type specified") - } - - if !isValidReportFormat(req.Format) { - return newBadRequestServiceError(errors.New("invalid format"), - "invalid export format specified") - } - - if req.StartDate.After(req.EndDate) { - return newBadRequestServiceError(errors.New("invalid date range"), - "start_date cannot be after end_date") - } - - if claims.Role == users.FacilityAdmin { - facilityID := claims.FacilityID - req.FacilityID = &facilityID - // Facility admins are scoped to their own facility; ignore any - // client-supplied multi-facility list so it can't widen scope. - req.FacilityIDs = nil - } - - if req.Type == FacilityComparisonReport { - if !claims.CanSwitchFacility() { - return newForbiddenServiceError(errors.New("role insufficient"), - "facility comparison reports require DepartmentAdmin or SystemAdmin role") - } - if len(req.FacilityIDs) == 0 { - return newBadRequestServiceError(errors.New("missing facility_ids"), - "facility_ids array is required for comparison reports") - } - if len(req.FacilityIDs) > maxFacilitiesInList { - return newBadRequestServiceError(errors.New("too many facilities"), - fmt.Sprintf("cannot compare more than %d facilities", maxFacilitiesInList)) - } - } - - if req.Type == ClassRosterReport && req.CohortID == nil { - return newBadRequestServiceError(errors.New("missing cohort_id"), - "cohort_id is required for class roster reports") - } - - if req.Type == ResidentProfileReport && req.UserID == nil { - return newBadRequestServiceError(errors.New("missing user_id"), - "user_id is required for resident profile reports") - } - - switch req.Type { - case AttendanceReport: - return srv.handleAttendanceReport(w, r, &req, claims) - case ProgramOutcomesReport: - return srv.handleProgramOutcomesReport(w, r, &req, claims) - case FacilityComparisonReport: - return srv.handleFacilityComparisonReport(w, r, &req, claims) - case ClassRosterReport: - return srv.handleClassRosterReport(w, r, &req, claims) - case ResidentProfileReport: - return srv.handleResidentProfileReport(w, r, &req, claims) - default: - return newBadRequestServiceError(errors.New("invalid report type"), - "invalid report type specified") - } -} - -func (srv *server) handleAttendanceReport(w http.ResponseWriter, r *http.Request, req *ReportGenerateRequest, claims *identity.Claims) error { - startTime := time.Now() - - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - defer cancel() - - rows, err := srv.store.GenerateAttendanceReport(ctx, req) - if err != nil { - srv.logReportFailure(claims.UserID, "attendance", req, startTime, 0) - return newDatabaseServiceError(err) - } - - report := AttendanceReportData{Data: rows} - - facilityName := "" - if req.FacilityID != nil { - if facility, err := srv.store.GetFacilityByID(int(*req.FacilityID)); err == nil && facility != nil { - facilityName = facility.Name - } - } - - residentName := "" - if req.UserID != nil { - if user, err := srv.store.GetUserByID(*req.UserID); err == nil && user != nil { - residentName = fmt.Sprintf("%s, %s", user.NameLast, user.NameFirst) - } - } - - if err := srv.exportReport(w, report, req.Format, req, facilityName, residentName); err != nil { - srv.logReportFailure(claims.UserID, "attendance", req, startTime, report.Len()) - return err - } - - srv.logReportSuccess(claims.UserID, "attendance", req, startTime, report.Len(), claims.FacilityID, 1) - return nil -} - -func (srv *server) handleProgramOutcomesReport(w http.ResponseWriter, r *http.Request, req *ReportGenerateRequest, claims *identity.Claims) error { - startTime := time.Now() - - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - defer cancel() - - rows, err := srv.store.GenerateProgramOutcomesReport(ctx, req) - if err != nil { - srv.logReportFailure(claims.UserID, "program_outcomes", req, startTime, 0) - return newDatabaseServiceError(err) - } - - // Surface the Active/Inactive column only when inactive programs are in scope. - report := ProgramOutcomesReportData{Data: rows, IncludeStatus: req.IncludeInactive} - - // Class-level breakdown is only meaningful for a single program at a single - // facility (the frontend enforces this before enabling the option). - if req.IncludeClassBreakdown { - if programID, facilityID, ok := singleBreakdownScope(req); ok { - classes, err := srv.store.GenerateProgramClassBreakdown(ctx, req, programID, facilityID) - if err != nil { - srv.logReportFailure(claims.UserID, "program_outcomes", req, startTime, 0) - return newDatabaseServiceError(err) - } - report.Classes = classes - } - } - - facilityName := "" - if req.FacilityID != nil { - if facility, err := srv.store.GetFacilityByID(int(*req.FacilityID)); err == nil && facility != nil { - facilityName = facility.Name - } - } - - if err := srv.exportReport(w, report, req.Format, req, facilityName, ""); err != nil { - srv.logReportFailure(claims.UserID, "program_outcomes", req, startTime, report.Len()) - return err - } - - srv.logReportSuccess(claims.UserID, "program_outcomes", req, startTime, report.Len(), claims.FacilityID, 1) - return nil -} - -func (srv *server) handleFacilityComparisonReport(w http.ResponseWriter, r *http.Request, req *ReportGenerateRequest, claims *identity.Claims) error { - startTime := time.Now() - - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - defer cancel() - - rows, err := srv.store.GenerateFacilityComparisonReport(ctx, req, req.FacilityIDs) - if err != nil { - srv.logReportFailure(claims.UserID, "facility_comparison", req, startTime, 0) - return newDatabaseServiceError(err) - } - - report := FacilityComparisonReportData{Data: rows} - - if err := srv.exportReport(w, report, req.Format, req, "Multiple Facilities", ""); err != nil { - srv.logReportFailure(claims.UserID, "facility_comparison", req, startTime, report.Len()) - return err - } - - srv.logReportSuccess(claims.UserID, "facility_comparison", req, startTime, report.Len(), 0, len(req.FacilityIDs)) - return nil -} - -func (srv *server) handleClassRosterReport(w http.ResponseWriter, r *http.Request, req *ReportGenerateRequest, claims *identity.Claims) error { - startTime := time.Now() - - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - defer cancel() - - rows, err := srv.store.GenerateClassRosterReport(ctx, req) - if err != nil { - srv.logReportFailure(claims.UserID, "class_roster", req, startTime, 0) - return newDatabaseServiceError(err) - } - - report := ClassRosterReportData{ - Data: rows, - IncludeIncompleteReason: req.IncludeIncompleteReason, - IncludeAttendanceRate: req.IncludeAttendanceRate, - IncludeEnrollmentDates: req.IncludeEnrollmentDates, - } - - facilityName := "" - if req.FacilityID != nil { - if facility, err := srv.store.GetFacilityByID(int(*req.FacilityID)); err == nil && facility != nil { - facilityName = facility.Name - } - } - - if err := srv.exportReport(w, report, req.Format, req, facilityName, ""); err != nil { - srv.logReportFailure(claims.UserID, "class_roster", req, startTime, report.Len()) - return err - } - - srv.logReportSuccess(claims.UserID, "class_roster", req, startTime, report.Len(), claims.FacilityID, 1) - return nil -} - -func (srv *server) handleResidentProfileReport(w http.ResponseWriter, r *http.Request, req *ReportGenerateRequest, claims *identity.Claims) error { - startTime := time.Now() - - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - defer cancel() - - rows, err := srv.store.GenerateResidentProfileReport(ctx, req) - if err != nil { - srv.logReportFailure(claims.UserID, "resident_profile", req, startTime, 0) - return newDatabaseServiceError(err) - } - - report := ResidentProfileReportData{Data: rows} - - residentName := "" - if req.UserID != nil { - if user, err := srv.store.GetUserByID(*req.UserID); err == nil && user != nil { - residentName = fmt.Sprintf("%s, %s", user.NameLast, user.NameFirst) - } - } - - if err := srv.exportReport(w, report, req.Format, req, "", residentName); err != nil { - srv.logReportFailure(claims.UserID, "resident_profile", req, startTime, report.Len()) - return err - } - - srv.logReportSuccess(claims.UserID, "resident_profile", req, startTime, report.Len(), claims.FacilityID, 1) - return nil -} - -func (srv *server) exportReport(w http.ResponseWriter, report reportExporter, format ReportFormat, req *ReportGenerateRequest, facilityName, residentName string) error { - switch format { - case FormatCSV: - csvData, err := report.ToCSV() - if err != nil { - return newInternalServerServiceError(err, "failed to format CSV") - } - - pdfConfig, err := report.ToPDF() - if err != nil { - return newInternalServerServiceError(err, "failed to generate PDF config") - } - filename := fmt.Sprintf("%s-Report-%s.csv", pdfConfig.Title, time.Now().Format("2006-01-02")) - w.Header().Set("Content-Type", "text/csv") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.WriteHeader(http.StatusOK) - - writer := csv.NewWriter(w) - if err := writer.WriteAll(csvData); err != nil { - return newInternalServerServiceError(err, "failed to write CSV") - } - return nil - - case FormatExcel: - f, err := report.ToExcel() - if err != nil { - return newInternalServerServiceError(err, "failed to create Excel file") - } - - pdfConfig, err := report.ToPDF() - if err != nil { - return newInternalServerServiceError(err, "failed to generate PDF config") - } - filename := fmt.Sprintf("%s-Report-%s.xlsx", pdfConfig.Title, time.Now().Format("2006-01-02")) - w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.WriteHeader(http.StatusOK) - - if err := f.Write(w); err != nil { - return newInternalServerServiceError(err, "failed to write Excel file") - } - return nil - - case FormatPDF: - config, err := report.ToPDF() - if err != nil { - return newInternalServerServiceError(err, "failed to generate PDF config") - } - - config.Filters = srv.buildFilterSummary(req, facilityName, residentName) - - templateName := "" - switch req.Type { - case AttendanceReport: - templateName = "attendance_report" - case ProgramOutcomesReport: - templateName = "program_outcomes_report" - case FacilityComparisonReport: - templateName = "facility_comparison_report" - case ClassRosterReport: - templateName = "class_roster_report" - case ResidentProfileReport: - templateName = "resident_profile_report" - default: - return newBadRequestServiceError(errors.New("unsupported report type"), "unsupported report type for PDF generation") - } - - pdfBytes, err := srv.reports.PDF(templateName, config) - - if err != nil { - logrus.WithError(err).Error("Failed to generate PDF with Jasper") - return newInternalServerServiceError(err, "failed to generate PDF") - } - - filename := fmt.Sprintf("%s-Report-%s.pdf", config.Title, time.Now().Format("2006-01-02")) - w.Header().Set("Content-Type", "application/pdf") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.WriteHeader(http.StatusOK) - - if _, err := w.Write(pdfBytes); err != nil { - return newInternalServerServiceError(err, "failed to write PDF") - } - return nil - - default: - return newBadRequestServiceError(errors.New("invalid format"), "invalid export format") - } -} - -func summarizeFilterValues(all bool, names []string) string { - if all || len(names) == 0 { - return "All" - } - const maxChars = 110 //set for a specific font size and style--dejavu sans - var b strings.Builder - shown := 0 - for _, n := range names { - piece := n - if b.Len() > 0 { - piece = ", " + n - } - if b.Len()+len(piece) > maxChars && shown > 0 { - break - } - b.WriteString(piece) - shown++ - } - if shown < len(names) { - fmt.Fprintf(&b, " +%d more", len(names)-shown) - } - return b.String() -} - -func (srv *server) buildFilterSummary(req *ReportGenerateRequest, facilityName, residentName string) []module.ReportFilter { - dateRange := fmt.Sprintf("%s - %s", - req.StartDate.Format("January 2, 2006"), - req.EndDate.Format("January 2, 2006")) - - if req.Type == ProgramOutcomesReport { - facilityValue := "All" - if facilityName != "" { - facilityValue = facilityName - } else if len(req.FacilityIDs) > 0 { - names, err := srv.store.GetFacilityNamesByIDs(req.FacilityIDs) - if err != nil { - logrus.WithError(err).Warn("failed to resolve facility names for report filter summary") - } - facilityValue = summarizeFilterValues(false, names) - } - - programValue := "All" - if len(req.ProgramIDs) > 0 { - names, err := srv.store.GetProgramNamesByIDs(req.ProgramIDs) - if err != nil { - logrus.WithError(err).Warn("failed to resolve program names for report filter summary") - } - programValue = summarizeFilterValues(false, names) - } - - typeNames := make([]string, 0, len(req.ProgramTypes)) - for _, pt := range req.ProgramTypes { - typeNames = append(typeNames, pt.HumanReadable()) - } - - return []module.ReportFilter{ - {Label: "Facilities", Value: facilityValue}, - {Label: "Programs", Value: programValue}, - {Label: "Program Types", Value: summarizeFilterValues(len(req.ProgramTypes) == 0, typeNames)}, - {Label: "Date Range", Value: dateRange}, - } - } - - if req.Type == AttendanceReport { - var filters []module.ReportFilter - classValue := "All classes" - if req.CohortID != nil { - if class, err := srv.store.GetCohortByID(int(*req.CohortID)); err == nil && class != nil { - classValue = class.ClassName - } - } - filters = append(filters, module.ReportFilter{Label: "Class", Value: classValue}) - - residentValue := "All residents" - if residentName != "" { - residentValue = residentName - } - filters = append(filters, module.ReportFilter{Label: "Resident", Value: residentValue}) - - filters = append(filters, module.ReportFilter{Label: "Date Range", Value: dateRange}) - - if facilityName != "" { - filters = append(filters, module.ReportFilter{Label: "Facility", Value: facilityName}) - } - return filters - } - - if req.Type == ClassRosterReport { - var filters []module.ReportFilter - if req.CohortID != nil { - if class, err := srv.store.GetCohortByID(int(*req.CohortID)); err == nil && class != nil { - filters = append(filters, module.ReportFilter{Label: "Class", Value: class.ClassName}) - } - } - filters = append(filters, module.ReportFilter{ - Label: "Enrollment Statuses", - Value: summarizeFilterValues(len(req.EnrollmentStatuses) == 0, req.EnrollmentStatuses), - }) - if facilityName != "" { - filters = append(filters, module.ReportFilter{Label: "Facility", Value: facilityName}) - } - return filters - } - - if req.Type == ResidentProfileReport { - if residentName != "" { - return []module.ReportFilter{{Label: "Resident", Value: residentName}} - } - return nil - } - - var filters []module.ReportFilter - - filters = append(filters, module.ReportFilter{Label: "Date Range", Value: dateRange}) - - if facilityName != "" { - filters = append(filters, module.ReportFilter{Label: "Facility", Value: facilityName}) - } - - if residentName != "" { - filters = append(filters, module.ReportFilter{Label: "Resident", Value: residentName}) - } - - if req.ClassStatus != nil && *req.ClassStatus != "" && *req.ClassStatus != "All" { - filters = append(filters, module.ReportFilter{Label: "Class Status", Value: *req.ClassStatus}) - } - - if len(req.ProgramTypes) > 0 { - var types []string - for _, pt := range req.ProgramTypes { - types = append(types, pt.HumanReadable()) - } - filters = append(filters, module.ReportFilter{Label: "Program Types", Value: strings.Join(types, ", ")}) - } - - if len(req.FundingTypes) > 0 { - var types []string - for _, ft := range req.FundingTypes { - types = append(types, ft.HumanReadable()) - } - filters = append(filters, module.ReportFilter{Label: "Funding Types", Value: strings.Join(types, ", ")}) - } - - return filters -} - -func singleBreakdownScope(req *ReportGenerateRequest) (programID, facilityID uint, ok bool) { - switch { - case req.ProgramID != nil: - programID = *req.ProgramID - case len(req.ProgramIDs) == 1: - programID = req.ProgramIDs[0] - default: - return 0, 0, false - } - switch { - case req.FacilityID != nil: - facilityID = *req.FacilityID - case len(req.FacilityIDs) == 1: - facilityID = req.FacilityIDs[0] - default: - return 0, 0, false - } - return programID, facilityID, true -} - -func isValidReportType(rt ReportType) bool { - switch rt { - case AttendanceReport, ProgramOutcomesReport, FacilityComparisonReport, - ClassRosterReport, ResidentProfileReport: - return true - default: - return false - } -} - -func isValidReportFormat(format ReportFormat) bool { - switch format { - case FormatCSV, FormatPDF, FormatExcel: - return true - default: - return false - } -} - -type reportExporter interface { - ToCSV() ([][]string, error) - ToExcel() (*excelize.File, error) - ToPDF() (module.Report, error) - Len() int -} - -func (srv *server) logReportSuccess(userID uint, reportType string, req *ReportGenerateRequest, startTime time.Time, rowCount int, facilityID uint, facilityCount int) { - dateRangeDays := int(req.EndDate.Sub(req.StartDate).Hours() / 24) - - fields := logrus.Fields{ - "audit": "report_generation", - "user_id": userID, - "report_type": reportType, - "facility_id": facilityID, - "format": string(req.Format), - "success": true, - "row_count": rowCount, - "processing_time_ms": time.Since(startTime).Milliseconds(), - "date_range_days": dateRangeDays, - "facility_count": facilityCount, - "timestamp": time.Now(), - } - - if reportType == "facility_comparison" && facilityCount > 1 { - fields["data_scope"] = "multi_facility" - } else if facilityCount == 1 { - fields["data_scope"] = "single_facility" - } - - logrus.WithFields(fields).Info("Report generated") -} - -func (srv *server) logReportFailure(userID uint, reportType string, req *ReportGenerateRequest, startTime time.Time, rowCount int) { - dateRangeDays := int(req.EndDate.Sub(req.StartDate).Hours() / 24) - - fields := logrus.Fields{ - "audit": "report_generation", - "user_id": userID, - "report_type": reportType, - "format": string(req.Format), - "success": false, - "row_count": rowCount, - "processing_time_ms": time.Since(startTime).Milliseconds(), - "date_range_days": dateRangeDays, - "timestamp": time.Now(), - } - - logrus.WithFields(fields).Error("Report generation failed") -} diff --git a/backend/src/modules/programs/reports_handler_test.go b/backend/src/modules/programs/reports_handler_test.go deleted file mode 100644 index 5869caed8..000000000 --- a/backend/src/modules/programs/reports_handler_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package programs - -import ( - "net/http/httptest" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// fakeReports records the report it is asked to render. -type fakeReports struct { - template string - report module.Report -} - -func (f *fakeReports) PDF(template string, report module.Report) ([]byte, error) { - f.template, f.report = template, report - return []byte("%PDF-fake"), nil -} - -// A PDF export hands core the report's template, table and filters, and -// answers with the PDF core renders. -func TestExportReportPDF(t *testing.T) { - reports := &fakeReports{} - srv := &server{store: &Store{newTestDB(t)}, reports: reports} - start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) - req := &ReportGenerateRequest{Type: AttendanceReport, Format: FormatPDF, StartDate: start, EndDate: end} - report := AttendanceReportData{Data: []AttendanceReportRow{{ - FacilityName: "North", ProgramName: "GED", ClassName: "Math", Date: start, - StudentLastName: "Doe", StudentFirstName: "Jane", DocID: "1001", AttendanceStatus: Present, - }}} - - w := httptest.NewRecorder() - require.NoError(t, srv.exportReport(w, report, FormatPDF, req, "North", "")) - - assert.Equal(t, "attendance_report", reports.template) - assert.Equal(t, "Attendance Records", reports.report.Title) - assert.Equal(t, [][]string{{"2026-03-01", "GED", "Math", "North", "Doe, Jane", "1001", "Present", ""}}, reports.report.Rows) - assert.Equal(t, []module.ReportFilter{ - {Label: "Class", Value: "All classes"}, - {Label: "Resident", Value: "All residents"}, - {Label: "Date Range", Value: "March 1, 2026 - March 31, 2026"}, - {Label: "Facility", Value: "North"}, - }, reports.report.Filters) - assert.Equal(t, "application/pdf", w.Header().Get("Content-Type")) - assert.Equal(t, "%PDF-fake", w.Body.String()) -} diff --git a/backend/src/modules/programs/reports_store.go b/backend/src/modules/programs/reports_store.go deleted file mode 100644 index 282f23d52..000000000 --- a/backend/src/modules/programs/reports_store.go +++ /dev/null @@ -1,374 +0,0 @@ -package programs - -import ( - "context" - "fmt" - "math" - "strings" -) - -var rosterStatusFilterMap = map[string]string{ - "Enrolled": "Enrolled", - "Completed": "Completed", - "Cancelled": "Cancelled", - "Withdrawn": "Incomplete: Withdrawn", - "Dropped": "Incomplete: Dropped", - "Segregated": "Incomplete: Segregated", - "Failed to Complete": "Incomplete: Failed to Complete", - "Transfered": "Incomplete: Transfered", -} - -const sessionsAttendedSubquery = `(SELECT COUNT(*) FROM program_class_event_attendance pcea - JOIN program_class_events pcev ON pcev.id = pcea.event_id - WHERE pcev.cohort_id = pce.cohort_id AND pcea.user_id = pce.user_id - AND pcea.attendance_status IN ('present', 'partial')) AS sessions_attended` -const sessionsTotalSubquery = `(SELECT COUNT(*) FROM program_class_event_attendance pcea - JOIN program_class_events pcev ON pcev.id = pcea.event_id - WHERE pcev.cohort_id = pce.cohort_id AND pcea.user_id = pce.user_id - AND pcea.attendance_status IS NOT NULL AND pcea.attendance_status != '') AS total_sessions` - -func (db *Store) GenerateAttendanceReport(ctx context.Context, req *ReportGenerateRequest) ([]AttendanceReportRow, error) { - var rows []AttendanceReportRow - - tx := db.WithContext(ctx).Table("program_class_event_attendance pcea"). - Select(` - f.name AS facility_name, - p.name AS program_name, - cl.name AS class_name, - DATE(pcea.date) AS date, - u.name_last AS student_last_name, - u.name_first AS student_first_name, - u.doc_id, - pcea.attendance_status, - NULL AS seat_time_minutes, - ( - SELECT CONCAT(admin.name_first, ' ', admin.name_last) - FROM user_account_history uah - JOIN users admin ON admin.id = uah.admin_id - WHERE uah.user_id = pcea.user_id - AND uah.action = 'attendance_recorded' - AND uah.session_date = DATE(pcea.date) - AND uah.class_name = cl.name - AND uah.attendance_status = pcea.attendance_status - ORDER BY uah.created_at DESC - LIMIT 1 - ) AS recorded_by, - pcea.note AS absence_reason - `). - Joins("JOIN program_class_events pce ON pce.id = pcea.event_id"). - Joins("JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Joins("JOIN program_classes cl ON cl.id = pc.class_id"). - Joins("JOIN programs p ON p.id = pc.program_id"). - Joins("JOIN users u ON u.id = pcea.user_id"). - Joins("JOIN facilities f ON f.id = u.facility_id"). - Where("DATE(pcea.date) >= ? AND DATE(pcea.date) <= ?", req.StartDate, req.EndDate). - Where("pcea.deleted_at IS NULL"). - Order("f.name, p.name, cl.name, pcea.date, u.name_last") - - if req.FacilityID != nil { - tx = tx.Where("f.id = ?", *req.FacilityID) - } - - if req.ProgramID != nil { - tx = tx.Where("p.id = ?", *req.ProgramID) - } - - if req.CohortID != nil { - tx = tx.Where("pc.id = ?", *req.CohortID) - } - - if req.UserID != nil { - tx = tx.Where("u.id = ?", *req.UserID) - } - - if err := tx.Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "attendance report") - } - - return rows, nil -} - -func (db *Store) GenerateProgramOutcomesReport(ctx context.Context, req *ReportGenerateRequest) ([]ProgramOutcomesReportRow, error) { - var rows []ProgramOutcomesReportRow - - typeAgg := "STRING_AGG(DISTINCT CAST(program_type AS TEXT), ',')" - if db.Name() == "sqlite" { - typeAgg = "GROUP_CONCAT(DISTINCT CAST(program_type AS TEXT))" - } - classStatus := "Active" - if req.ClassStatus != nil && *req.ClassStatus != "" { - classStatus = *req.ClassStatus - } - classClause := "WHERE pc.status = 'Active'" - switch classStatus { - case "Not Active": - classClause = "WHERE pc.status != 'Active'" - case "All": - classClause = "" - } - - args := []any{req.StartDate, req.EndDate} - var whereClauses []string - if len(req.FacilityIDs) > 0 { - whereClauses = append(whereClauses, "fp.facility_id IN ?") - args = append(args, req.FacilityIDs) - } else if req.FacilityID != nil { - whereClauses = append(whereClauses, "fp.facility_id = ?") - args = append(args, *req.FacilityID) - } - if len(req.ProgramIDs) > 0 { - whereClauses = append(whereClauses, "p.id IN ?") - args = append(args, req.ProgramIDs) - } else if req.ProgramID != nil { - whereClauses = append(whereClauses, "p.id = ?") - args = append(args, *req.ProgramID) - } - if len(req.ProgramTypes) > 0 { - whereClauses = append(whereClauses, "EXISTS (SELECT 1 FROM program_types pt2 WHERE pt2.program_id = p.id AND pt2.program_type IN ?)") - args = append(args, req.ProgramTypes) - } - if len(req.FundingTypes) > 0 { - whereClauses = append(whereClauses, "p.funding_type IN ?") - args = append(args, req.FundingTypes) - } - // Archived programs are never exported; the caller opts in to inactive - // (is_active = false) programs via IncludeInactive. - whereClauses = append(whereClauses, "p.archived_at IS NULL") - if !req.IncludeInactive { - whereClauses = append(whereClauses, "p.is_active = ?") - args = append(args, true) - } - whereClause := "" - if len(whereClauses) > 0 { - whereClause = "WHERE " + strings.Join(whereClauses, " AND ") - } - - query := fmt.Sprintf(`WITH active_classes AS ( - SELECT pc.id AS cohort_id, pc.program_id, pc.facility_id, pc.capacity - FROM program_class_cohorts pc - %s - ), - enrollment_stats AS ( - SELECT pce.cohort_id, - -- "Enrolled in Range": enrollments whose enrolled_at falls in the window. - COUNT(CASE WHEN pce.enrolled_at BETWEEN ? AND ? THEN 1 END) AS total_enrollments, - -- "Currently Enrolled": live snapshot of active enrollments, not date-scoped. - COUNT(CASE WHEN pce.enrollment_status = 'Enrolled' THEN 1 END) AS active_enrollments - FROM program_class_enrollments pce - GROUP BY pce.cohort_id - ), - program_types_agg AS ( - SELECT program_id, COALESCE(%s, 'N/A') AS program_type - FROM program_types - GROUP BY program_id - ) - SELECT - p.name AS program_name, - p.is_active AS is_active, - COALESCE(pta.program_type, 'N/A') AS program_type, - COUNT(DISTINCT fp.facility_id) AS facilities_active, - COUNT(DISTINCT ac.cohort_id) AS total_classes, - COALESCE(SUM(es.active_enrollments), 0) AS active_enrollments, - COALESCE(SUM(es.total_enrollments), 0) AS total_enrollments, - COALESCE(SUM(ac.capacity), 0) AS total_capacity - FROM programs p - JOIN facilities_programs fp ON fp.program_id = p.id - LEFT JOIN active_classes ac ON ac.program_id = fp.program_id AND ac.facility_id = fp.facility_id - LEFT JOIN enrollment_stats es ON es.cohort_id = ac.cohort_id - LEFT JOIN program_types_agg pta ON pta.program_id = p.id - %s - GROUP BY p.id, p.name, pta.program_type - ORDER BY p.name`, classClause, typeAgg, whereClause) - - if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "program outcomes report") - } - - for i := range rows { //FIXME business logic left here for now, need to clean this entire file up when the time is right - if rows[i].TotalCapacity > 0 { - rows[i].Utilization = int(math.Round( - 100.0 * float64(rows[i].ActiveEnrollments) / float64(rows[i].TotalCapacity), - )) - } - } - - return rows, nil -} - -func (db *Store) GenerateProgramClassBreakdown(ctx context.Context, req *ReportGenerateRequest, programID, facilityID uint) ([]ProgramClassBreakdownRow, error) { - var rows []ProgramClassBreakdownRow - - tx := db.WithContext(ctx).Table("program_class_cohorts pc"). - Select(` - cl.name AS class_name, - pc.status AS status, - COALESCE(pc.credit_hours, cl.credit_hours) AS credit_hours, - pc.capacity AS capacity, - COUNT(CASE WHEN pce.enrollment_status = 'Enrolled' THEN 1 END) AS active_enrollments, - COUNT(CASE WHEN pce.enrolled_at BETWEEN ? AND ? THEN 1 END) AS range_enrollments - `, req.StartDate, req.EndDate). - Joins("JOIN program_classes cl ON cl.id = pc.class_id"). - Joins("LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id AND pce.deleted_at IS NULL"). - Where("pc.program_id = ? AND pc.facility_id = ?", programID, facilityID) - - switch { - case req.ClassStatus != nil && *req.ClassStatus == "All": - case req.ClassStatus != nil && *req.ClassStatus == "Not Active": - tx = tx.Where("pc.status != ?", "Active") - default: - tx = tx.Where("pc.status = ?", "Active") - } - - tx = tx.Group("pc.id, cl.name, cl.credit_hours, pc.status, pc.credit_hours, pc.capacity"). - Order("cl.name") - - if err := tx.Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "program class breakdown") - } - - return rows, nil -} - -func (db *Store) GenerateFacilityComparisonReport(ctx context.Context, req *ReportGenerateRequest, facilityIDs []uint) ([]FacilityComparisonReportRow, error) { - var rows []FacilityComparisonReportRow - - tx := db.WithContext(ctx).Table("facilities f"). - Select(` - f.name AS facility_name, - COUNT(DISTINCT fp.program_id) AS total_programs, - COUNT(DISTINCT CASE WHEN p.deleted_at IS NULL AND pce.id IS NOT NULL THEN fp.program_id END) AS active_programs, - COUNT(DISTINCT CASE WHEN pce.enrolled_at IS NOT NULL THEN pce.id END) AS total_enrollments, - COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' THEN pce.id END) AS active_enrollments, - CASE - WHEN COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END) = 0 THEN 0 - ELSE (CAST(COUNT(CASE WHEN pce.enrollment_status = 'Completed' THEN 1 END) AS REAL) * 100.0 / - NULLIF(COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END), 0)) - END AS completion_rate, - COALESCE( - SUM(CASE WHEN pcea.attendance_status = 'present' THEN 1 ELSE 0 END) * 100.0 / - NULLIF(COUNT(CASE WHEN pcea.attendance_status IS NOT NULL AND pcea.attendance_status != '' THEN 1 END), 0), - 0 - ) AS attendance_rate, - COALESCE( - (SELECT CAST(pt2.program_type AS TEXT) - FROM program_types pt2 - JOIN programs p2 ON p2.id = pt2.program_id - JOIN facilities_programs fp2 ON fp2.program_id = p2.id - WHERE fp2.facility_id = f.id - GROUP BY pt2.program_type - ORDER BY COUNT(*) DESC - LIMIT 1), - 'N/A') AS top_program_type, - COALESCE((SELECT SUM(credit_hours) FROM program_class_cohorts WHERE facility_id = f.id), 0) AS total_credit_hours, - 0 AS certificates_earned, - MAX(pcea.created_at) AS last_activity_date - `). - Joins("LEFT JOIN facilities_programs fp ON fp.facility_id = f.id"). - Joins("LEFT JOIN programs p ON p.id = fp.program_id"). - Joins("LEFT JOIN program_class_cohorts pc ON pc.program_id = p.id AND pc.facility_id = f.id"). - Joins("LEFT JOIN program_class_enrollments pce ON pce.cohort_id = pc.id AND pce.enrolled_at <= ?", req.EndDate). - Joins("LEFT JOIN program_class_events pcev ON pcev.cohort_id = pc.id"). - Joins("LEFT JOIN program_class_event_attendance pcea ON pcea.event_id = pcev.id AND pcea.user_id = pce.user_id AND pcea.deleted_at IS NULL AND DATE(pcea.date) BETWEEN ? AND ?", req.StartDate, req.EndDate). - Where("f.id IN ?", facilityIDs) - - if len(req.ProgramTypes) > 0 { - tx = tx.Where("EXISTS (SELECT 1 FROM program_types pt WHERE pt.program_id = p.id AND pt.program_type IN ?)", req.ProgramTypes) - } - - if len(req.FundingTypes) > 0 { - tx = tx.Where("p.funding_type IN ?", req.FundingTypes) - } - - tx = tx.Group("f.id, f.name"). - Order("f.name") - - if err := tx.Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "facility comparison report") - } - - return rows, nil -} - -func mapRosterStatuses(labels []string) []string { - mapped := make([]string, 0, len(labels)) - for _, label := range labels { - if stored, ok := rosterStatusFilterMap[label]; ok { - mapped = append(mapped, stored) - } else { - mapped = append(mapped, label) - } - } - return mapped -} - -func (db *Store) GenerateClassRosterReport(ctx context.Context, req *ReportGenerateRequest) ([]ClassRosterReportRow, error) { - var rows []ClassRosterReportRow - - tx := db.WithContext(ctx).Table("program_class_enrollments pce"). - Select(` - u.name_last, - u.name_first, - u.doc_id, - pce.enrollment_status, - pce.enrolled_at, - pce.enrollment_ended_at AS ended_at, - `+sessionsAttendedSubquery+`, - `+sessionsTotalSubquery+` - `). - Joins("JOIN users u ON u.id = pce.user_id"). - Joins("JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Where("pce.cohort_id = ?", *req.CohortID). - Where("pce.deleted_at IS NULL"). - Order("u.name_last, u.name_first") - - if len(req.EnrollmentStatuses) > 0 { - tx = tx.Where("pce.enrollment_status IN ?", mapRosterStatuses(req.EnrollmentStatuses)) - } - - if req.FacilityID != nil { - tx = tx.Where("pc.facility_id = ?", *req.FacilityID) - } - - if err := tx.Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "class roster report") - } - - return rows, nil -} - -func (db *Store) GenerateResidentProfileReport(ctx context.Context, req *ReportGenerateRequest) ([]ResidentProfileReportRow, error) { - var rows []ResidentProfileReportRow - - tx := db.WithContext(ctx).Table("program_class_enrollments pce"). - Select(` - u.name_last, - u.name_first, - u.doc_id, - f.name AS facility_name, - p.name AS program_name, - cl.name AS class_name, - pce.enrollment_status, - pce.enrolled_at, - pce.enrollment_ended_at AS ended_at, - `+sessionsAttendedSubquery+`, - `+sessionsTotalSubquery+` - `). - Joins("JOIN users u ON u.id = pce.user_id"). - Joins("JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - Joins("JOIN program_classes cl ON cl.id = pc.class_id"). - Joins("JOIN programs p ON p.id = pc.program_id"). - Joins("JOIN facilities f ON f.id = pc.facility_id"). - Where("pce.user_id = ?", *req.UserID). - Where("pce.deleted_at IS NULL"). - Order("pce.enrolled_at DESC") - - if req.FacilityID != nil { - tx = tx.Where("pc.facility_id = ?", *req.FacilityID) - } - - if err := tx.Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "resident profile report") - } - - return rows, nil -} diff --git a/backend/src/modules/programs/rooms_store.go b/backend/src/modules/programs/rooms_store.go deleted file mode 100644 index c6325c1f7..000000000 --- a/backend/src/modules/programs/rooms_store.go +++ /dev/null @@ -1,362 +0,0 @@ -package programs - -import ( - "errors" - "fmt" - "sort" - "time" - - "github.com/sirupsen/logrus" - "github.com/teambition/rrule-go" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -func (db *Store) GetRoomsForFacility(facilityID uint) ([]Room, error) { - var rooms []Room - if err := db.Where("facility_id = ?", facilityID).Order("name ASC").Find(&rooms).Error; err != nil { - return nil, newGetRecordsDBError(err, "rooms") - } - return rooms, nil -} - -func (db *Store) GetRoomByIDForFacility(roomID, facilityID uint) (*Room, error) { - var room Room - if err := db.Where("id = ? AND facility_id = ?", roomID, facilityID).First(&room).Error; err != nil { - return nil, newNotFoundDBError(err, "rooms") - } - return &room, nil -} - -func (db *Store) CreateRoom(room *Room) (*Room, error) { - if err := Validate().Struct(room); err != nil { - return nil, newCreateDBError(err, "room") - } - if err := db.Create(room).Error; err != nil { - return nil, newCreateDBError(err, "room") - } - return room, nil -} - -func LockAndCheckConflicts(tx *gorm.DB, req *ConflictCheckRequest) ([]RoomConflict, error) { - db := &Store{tx} - if req.RoomID != 0 { - var room Room - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&room, req.RoomID).Error; err != nil { - return nil, newNotFoundDBError(err, "room") - } - } - // The instructor isn't locked: users is a core table, and Programs' role - // can read it but not lock its rows. - if req.InstructorID != 0 { - var instructor User - if err := tx.First(&instructor, req.InstructorID).Error; err != nil { - return nil, newNotFoundDBError(err, "instructor") - } - } - return db.CheckConflicts(req) -} - -const maxConflictsToReturn = 50 - -func (db *Store) CheckConflicts(req *ConflictCheckRequest) ([]RoomConflict, error) { - if req.RoomID == 0 && req.InstructorID == 0 { - return nil, nil - } - w, err := db.prepareConflictWindow(req) - if err != nil { - return nil, err - } - var conflicts []RoomConflict - if req.RoomID != 0 { - roomConflicts, err := checkRoomRRuleConflicts(db, w, req) - if err != nil { - return nil, err - } - conflicts = append(conflicts, roomConflicts...) - } - if req.InstructorID != 0 { - instructorConflicts, err := checkInstructorRRuleConflicts(db, w, req) - if err != nil { - return nil, err - } - conflicts = append(conflicts, instructorConflicts...) - } - return conflicts, nil -} - -func (db *Store) CheckRRuleConflicts(req *ConflictCheckRequest) ([]RoomConflict, error) { - w, err := db.prepareConflictWindow(req) - if err != nil { - return nil, err - } - return checkRoomRRuleConflicts(db, w, req) -} - -type conflictWindow struct { - occurrences []time.Time - duration time.Duration - tz *time.Location - facilityTZ string - rangeStart time.Time - rangeEnd time.Time -} - -func (db *Store) prepareConflictWindow(req *ConflictCheckRequest) (*conflictWindow, error) { - if req.RecurrenceRule == "" { - return nil, newDBError(errors.New("recurrence rule is required"), "invalid conflict check request") - } - if req.Duration == "" { - return nil, newDBError(errors.New("duration is required"), "invalid conflict check request") - } - - var facility Facility - if err := db.Select("timezone").First(&facility, req.FacilityID).Error; err != nil { - return nil, newGetRecordsDBError(err, "facility") - } - - tz, err := time.LoadLocation(facility.Timezone) - if err != nil { - tz = time.UTC - } - - tempEvent := &ProgramClassEvent{RecurrenceRule: req.RecurrenceRule} - rule, err := tempEvent.GetRRuleWithTimezone(facility.Timezone) - if err != nil { - return nil, newDBError(err, "invalid recurrence rule") - } - - duration, err := time.ParseDuration(req.Duration) - if err != nil { - return nil, newDBError(err, "invalid duration") - } - - now := time.Now() - rangeStart := rule.GetDTStart() - if rangeStart.Before(now.AddDate(-1, 0, 0)) { - rangeStart = now.AddDate(-1, 0, 0) - } - until := rule.GetUntil() - if until.IsZero() || until.Before(rangeStart) { - until = rangeStart.AddDate(1, 0, 0) - } - if until.After(rangeStart.AddDate(1, 0, 0)) { - until = rangeStart.AddDate(1, 0, 0) - } - - return &conflictWindow{ - occurrences: rule.Between(rangeStart, until, true), - duration: duration, - tz: tz, - facilityTZ: facility.Timezone, - rangeStart: rangeStart, - rangeEnd: until, - }, nil -} - -func buildConflictsFromBookings(db *Store, w *conflictWindow, bookings []RoomBooking, req *ConflictCheckRequest, conflictType string) []RoomConflict { - var conflicts []RoomConflict - - classIDs := make(map[uint]struct{}) - for _, booking := range bookings { - classIDs[booking.ClassID] = struct{}{} - } - - classNamesCache := make(map[uint]string) - if len(classIDs) > 0 { - ids := make([]uint, 0, len(classIDs)) - for id := range classIDs { - ids = append(ids, id) - } - // The displayed name lives on the parent class, so this has to join -- a cohort - // has no name of its own. Both tables have a `name`, so qualify every column. - var classes []ProgramClassCohort - if err := db.Model(&ProgramClassCohort{}). - Select("program_class_cohorts.id, pc.name AS class_name"). - Joins("JOIN program_classes pc ON pc.id = program_class_cohorts.class_id"). - Where("program_class_cohorts.id IN ?", ids). - Find(&classes).Error; err != nil { - logrus.Warnf("failed to batch fetch class names for conflict display: %v", err) - } - for _, c := range classes { - classNamesCache[c.ID] = c.ClassName - } - } - - for _, occ := range w.occurrences { - if len(conflicts) >= maxConflictsToReturn { - break - } - endTime := occ.Add(w.duration) - for _, booking := range bookings { - if req.ExcludeEventID != nil && booking.EventID == *req.ExcludeEventID { - continue - } - if req.ExcludeClassID != nil && booking.ClassID == *req.ExcludeClassID { - continue - } - if hasLocalTimeOverlap(occ, endTime, booking.StartTime, booking.EndTime, w.tz) { - className := classNamesCache[booking.ClassID] - if className == "" { - className = "Unknown Class" - } - conflicts = append(conflicts, RoomConflict{ - ConflictingEventID: booking.EventID, - ConflictingClassID: booking.ClassID, - ClassName: className, - StartTime: booking.StartTime, - EndTime: booking.EndTime, - ConflictType: conflictType, - }) - if len(conflicts) >= maxConflictsToReturn { - break - } - } - } - } - - return conflicts -} - -func checkRoomRRuleConflicts(db *Store, w *conflictWindow, req *ConflictCheckRequest) ([]RoomConflict, error) { - bookings, err := db.getRoomBookingsInRange(req.FacilityID, req.RoomID, w.rangeStart, w.rangeEnd, w.facilityTZ) - if err != nil { - return nil, err - } - return buildConflictsFromBookings(db, w, bookings, req, ConflictTypeRoom), nil -} - -func (db *Store) getRoomBookingsInRange(facilityID, roomID uint, rangeStart, rangeEnd time.Time, facilityTimezone string) ([]RoomBooking, error) { - var events []ProgramClassEvent - if err := db.Table("program_class_events e"). - Select("DISTINCT e.*"). - Joins("JOIN program_class_cohorts c ON c.id = e.cohort_id"). - Joins("LEFT JOIN program_class_event_overrides o ON o.event_id = e.id AND o.deleted_at IS NULL"). - Where("c.facility_id = ? AND e.deleted_at IS NULL AND (e.room_id = ? OR o.room_id = ?)", facilityID, roomID, roomID). - Preload("Overrides"). - Find(&events).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_events") - } - - var bookings []RoomBooking - for _, event := range events { - eventBookings, err := expandEventToBookings(event, rangeStart, rangeEnd, roomID, facilityTimezone) - if err != nil { - return nil, err - } - bookings = append(bookings, eventBookings...) - } - return bookings, nil -} - -func expandEventToBookings(event ProgramClassEvent, rangeStart, rangeEnd time.Time, targetRoomID uint, facilityTimezone string) ([]RoomBooking, error) { - var bookings []RoomBooking - - if event.RoomID == nil { - return bookings, nil - } - - rule, err := event.GetRRuleWithTimezone(facilityTimezone) - if err != nil { - return nil, fmt.Errorf("invalid recurrence rule for event %d: %w", event.ID, err) - } - - duration, err := time.ParseDuration(event.Duration) - if err != nil { - return nil, fmt.Errorf("invalid duration '%s' for event %d: %w", event.Duration, event.ID, err) - } - - cancelledDates := make(map[string]bool) - rescheduledDates := make(map[string]ProgramClassEventOverride) - for _, override := range event.Overrides { - overrideRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("skipping override %d with invalid rrule: %v", override.ID, err) - continue - } - overrideOccurrences := overrideRule.Between(rangeStart, rangeEnd, true) - for _, occ := range overrideOccurrences { - dateKey := occ.Format("2006-01-02") - if override.IsCancelled { - cancelledDates[dateKey] = true - } else { - rescheduledDates[dateKey] = override - } - } - } - - occurrences := rule.Between(rangeStart, rangeEnd, true) - for _, occ := range occurrences { - dateKey := occ.Format("2006-01-02") - if cancelledDates[dateKey] { - continue - } - if _, rescheduled := rescheduledDates[dateKey]; rescheduled { - continue - } - if *event.RoomID != targetRoomID { - continue - } - bookings = append(bookings, RoomBooking{ - RoomID: *event.RoomID, - StartTime: occ, - EndTime: occ.Add(duration), - EventID: event.ID, - ClassID: event.CohortID, - IsOverride: false, - }) - } - - for _, override := range event.Overrides { - if override.IsCancelled { - continue - } - overrideRoomID := event.RoomID - if override.RoomID != nil { - overrideRoomID = override.RoomID - } - if overrideRoomID == nil || *overrideRoomID != targetRoomID { - continue - } - overrideRule, err := rrule.StrToRRule(override.OverrideRrule) - if err != nil { - logrus.Warnf("skipping override %d with invalid rrule: %v", override.ID, err) - continue - } - overrideDuration, err := time.ParseDuration(override.Duration) - if err != nil { - overrideDuration = duration - } - for _, occ := range overrideRule.Between(rangeStart, rangeEnd, true) { - bookings = append(bookings, RoomBooking{ - RoomID: *overrideRoomID, - StartTime: occ, - EndTime: occ.Add(overrideDuration), - EventID: event.ID, - ClassID: event.CohortID, - IsOverride: true, - }) - } - } - - sort.Slice(bookings, func(i, j int) bool { - return bookings[i].StartTime.Before(bookings[j].StartTime) - }) - - return bookings, nil -} - -func hasLocalTimeOverlap(start1, end1, start2, end2 time.Time, tz *time.Location) bool { - local1Start := start1.In(tz) - local1End := end1.In(tz) - local2Start := start2.In(tz) - local2End := end2.In(tz) - if local1Start.YearDay() != local2Start.YearDay() || local1Start.Year() != local2Start.Year() { - return false - } - t1Start := local1Start.Hour()*60 + local1Start.Minute() - t1End := local1End.Hour()*60 + local1End.Minute() - t2Start := local2Start.Hour()*60 + local2Start.Minute() - t2End := local2End.Hour()*60 + local2End.Minute() - return t1Start < t2End && t2Start < t1End -} diff --git a/backend/src/modules/programs/store.go b/backend/src/modules/programs/store.go deleted file mode 100644 index 9183b39c9..000000000 --- a/backend/src/modules/programs/store.go +++ /dev/null @@ -1,108 +0,0 @@ -package programs - -import ( - "fmt" - "net/http" - "strings" - "sync" - - "github.com/go-playground/validator/v10" - log "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// Store is Programs' database. Its queries moved from core's database package -// unchanged (EN-194). It is exported for the integration tests, which call -// the queries directly; depguard keeps core from using it. -type Store struct{ *gorm.DB } - -// WithUserContext is the store with r's context, so the audit hooks record -// the caller as the row's create or update user. -func (srv *server) WithUserContext(r *http.Request) *Store { - return &Store{srv.store.WithContext(r.Context())} -} - -// Validate is the validator for Programs' models and requests. -var Validate = sync.OnceValue(func() *validator.Validate { - return validator.New(validator.WithRequiredStructEnabled()) -}) - -// The reads of core's users and facilities tables that Programs makes, copied -// from core's database package. - -func (db *Store) GetUserByID(id uint) (*User, error) { - user := User{} - if err := db.First(&user, id).Error; err != nil { - return nil, newNotFoundDBError(err, "users") - } - return &user, nil -} - -func (db *Store) DeactivatedUsersPresent(userIDs []int) (bool, error) { - if len(userIDs) == 0 { - return false, nil - } - var count int64 - if err := db.Model(&User{}).Where("id IN (?) AND deactivated_at IS NOT NULL", userIDs).Count(&count).Error; err != nil { - log.Errorf("Error checking deactivated users: %v", err) - return false, newGetRecordsDBError(err, "users") - } - return count > 0, nil -} - -func (db *Store) GetAllFacilitiesOrdered() ([]Facility, error) { - var facilities []Facility - if err := db.Order("name").Find(&facilities).Error; err != nil { - return nil, newGetRecordsDBError(err, "facilities") - } - return facilities, nil -} - -func (db *Store) GetFacilityByID(id int) (*Facility, error) { - var facility Facility - if err := db.Where("id = ?", fmt.Sprintf("%d", id)).First(&facility).Error; err != nil { - return nil, newNotFoundDBError(err, "facilities") - } - return &facility, nil -} - -func (db *Store) GetFacilityNamesByIDs(ids []uint) ([]string, error) { - var names []string - if len(ids) == 0 { - return names, nil - } - if err := db.Model(&Facility{}).Where("id IN ?", ids).Order("name").Pluck("name", &names).Error; err != nil { - return nil, newGetRecordsDBError(err, "facilities") - } - return names, nil -} - -func adjustUserOrderBy(arg string) string { - if strings.Contains(arg, "name_last") { - return arg + ", name_first" - } - return arg -} - -func StringPtr(s string) *string { - return &s -} - -// NilIfBlank returns nil for empty or whitespace-only strings so they are stored as NULL. -func NilIfBlank(s string) *string { - if strings.TrimSpace(s) == "" { - return nil - } - return &s -} - -func UintPtr(u uint) *uint { - return &u -} - -func DerefUint(ptr *uint) uint { - if ptr == nil { - return 0 - } - return *ptr -} diff --git a/backend/src/modules/programs/users_handler.go b/backend/src/modules/programs/users_handler.go deleted file mode 100644 index 9c49be60f..000000000 --- a/backend/src/modules/programs/users_handler.go +++ /dev/null @@ -1,258 +0,0 @@ -package programs - -import ( - "context" - "encoding/csv" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/UnlockedLabs/unlocked-contracts/features" - "github.com/UnlockedLabs/unlocked-contracts/providerplatforms" - "github.com/UnlockedLabs/unlocked-contracts/users" - log "github.com/sirupsen/logrus" -) - -func (srv *server) handleGetUserPrograms(w http.ResponseWriter, r *http.Request, log sLog) error { - id := r.PathValue("id") - userId, err := strconv.Atoi(id) - if err != nil { - return newInvalidIdServiceError(err, "error converting user_id") - } - queryCtx := getQueryContext(r) - // The resident profile builds its active / completed / incomplete cards from - // the full enrollment set, so return all of them rather than a single page - // (otherwise residents with >per_page enrollments lose rows, e.g. completed - // programs falling off page 1). - queryCtx.All = true - userPrograms, err := srv.store.GetUserProgramInfo(&queryCtx, userId) - if err != nil { - return newDatabaseServiceError(err) - } - for i := range userPrograms { - userPrograms[i].CalculateAttendancePercentage() - userPrograms[i].Schedule = FormatScheduleFromRRule(userPrograms[i].RecurrenceRule) - } - claims := claimsFrom(r) - if claims.HasFeature(features.ProviderPlatforms) { - canvasPrograms := srv.fetchCanvasUserPrograms(userId) - userPrograms = append(userPrograms, canvasPrograms...) - queryCtx.Total += int64(len(canvasPrograms)) - } - return writePaginatedResponse(w, http.StatusOK, userPrograms, queryCtx.IntoMeta()) -} - -func (srv *server) fetchCanvasUserPrograms(userID int) []ResidentProgramClassInfo { - ctx := context.Background() - mappings, err := srv.providerPlatforms.UserMappings(ctx, uint(userID)) - if err != nil || len(mappings) == 0 { - return nil - } - providers, err := srv.activeProviders(ctx) - if err != nil { - return nil - } - providerMap := make(map[uint]providerplatforms.Provider, len(providers)) - for _, p := range providers { - providerMap[p.ID] = p - } - var result []ResidentProgramClassInfo - for _, mapping := range mappings { - provider, ok := providerMap[mapping.ProviderID] - if !ok || !isLiveProgramProvider(&provider) { - continue - } - if isCanvasProvider(&provider) { - result = append(result, srv.fetchCanvasCoursesForUser(&provider, mapping.ExternalUserID)...) - continue - } - result = append(result, srv.fetchLiveCoursesForUser(&provider, mapping.ExternalUserID)...) - } - return result -} - -// fetchLiveCoursesForUser builds a resident's program rows for a live provider -// that reports its own per-user enrollments. Canvas has a dedicated endpoint and -// is handled by fetchCanvasCoursesForUser instead. -func (srv *server) fetchLiveCoursesForUser(provider *providerplatforms.Provider, externalUserID string) []ResidentProgramClassInfo { - reader, err := srv.newLiveProgramProvider(provider) - if err != nil { - return nil - } - lister, ok := reader.(liveUserCourseLister) - if !ok { - return nil - } - courses, err := lister.ListUserCourses(context.Background(), externalUserID) - if err != nil { - log.WithError(err).Warnf("failed to fetch courses for user %s on provider %d", externalUserID, provider.ID) - return nil - } - programID := CanvasProgramIDOffset + provider.ID - result := make([]ResidentProgramClassInfo, 0, len(courses)) - for _, course := range courses { - enrollment := Enrolled - endDate := "" - if course.endDt != nil { - endDate = course.endDt.Format(time.RFC3339) - if !course.endDt.After(time.Now()) { - enrollment = EnrollmentCompleted - } - } - // A provider can mark a class finished without giving it an end date -- - // Essential Ed does exactly that when active is 0 -- so the status wins. - if course.status == Completed { - enrollment = EnrollmentCompleted - } - startDate := "" - if !course.startDt.IsZero() { - startDate = course.startDt.Format(time.RFC3339) - } - result = append(result, ResidentProgramClassInfo{ - ProgramName: provider.Name, - ClassName: course.name, - ProgramID: programID, - EnrollmentStatus: enrollment, - StartDate: startDate, - EndDate: endDate, - AttendancePercentage: "--", - IsCanvas: true, - Source: providerSourceLabel(provider), - }) - } - return result -} - -func (srv *server) fetchCanvasCoursesForUser(provider *providerplatforms.Provider, canvasUserID string) []ResidentProgramClassInfo { - apiURL := fmt.Sprintf("%s/api/v1/users/%s/courses?enrollment_state=active&per_page=100", provider.BaseURL, canvasUserID) - var result []ResidentProgramClassInfo - for apiURL != "" { - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return result - } - req.Header.Add("Authorization", "Bearer "+provider.AccessKey) - req.Header.Add("Accept", "application/json") - resp, err := srv.Client.Do(req) - if err != nil { - return result - } - var courses []map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&courses); err != nil { - _ = resp.Body.Close() - return result - } - _ = resp.Body.Close() - programID := CanvasProgramIDOffset + provider.ID - for _, course := range courses { - name, _ := course["name"].(string) - startAt, _ := course["start_at"].(string) - endAt, _ := course["end_at"].(string) - enrollment := EnrollmentCompleted - if endAt == "" { - enrollment = Enrolled - } else if t, err := time.Parse(time.RFC3339, endAt); err == nil && t.After(time.Now()) { - enrollment = Enrolled - } - result = append(result, ResidentProgramClassInfo{ - ProgramName: provider.Name, - ClassName: name, - ProgramID: programID, - EnrollmentStatus: enrollment, - StartDate: startAt, - EndDate: endAt, - AttendancePercentage: "--", - IsCanvas: true, - Source: providerSourceLabel(provider), - }) - } - apiURL = NextPageURL(resp.Header.Get("Link")) - } - return result -} - -func (srv *server) handleGetUserAttendanceTrend(w http.ResponseWriter, r *http.Request, log sLog) error { - id := r.PathValue("id") - userId, err := strconv.Atoi(id) - if err != nil { - return newInvalidIdServiceError(err, "error converting user_id") - } - weeks := 8 - if wk := r.URL.Query().Get("weeks"); wk != "" { - if parsed, err := strconv.Atoi(wk); err == nil && parsed > 0 { - weeks = parsed - } - } - service := NewUsersService(srv.store) - trends, err := service.GetWeeklyAttendanceTrend(r.Context(), userId, weeks) - if err != nil { - return newDatabaseServiceError(err) - } - return writeJsonResponse(w, http.StatusOK, trends) -} - -func (srv *server) handleExportResidentAttendanceCSV(w http.ResponseWriter, r *http.Request, log sLog) error { - userID, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "user ID") - } - - claims := claimsFrom(r) - queryCtx := getQueryContext(r) - - user, err := srv.store.GetUserByID(uint(userID)) - if err != nil { - log.add("user_id", userID) - return newDatabaseServiceError(err) - } - - if claims.Role == users.FacilityAdmin && user.FacilityID != claims.FacilityID { - return newUnauthorizedServiceError() - } - - allFacilities := claims.CanSwitchFacility() || queryCtx.All - var classID *uint - if cid := r.URL.Query().Get("cohort_id"); cid != "" { - parsed, err := strconv.Atoi(cid) - if err != nil { - return newInvalidIdServiceError(err, "class_id") - } - ref := uint(parsed) - classID = &ref - } - csvData, err := srv.store.GetResidentAttendanceCSVData(r.Context(), uint(userID), claims.FacilityID, allFacilities, classID) - if err != nil { - log.add("user_id", userID) - return newDatabaseServiceError(err) - } - - toCSV, err := ResidentAttendanceToCSVFormat(csvData) - if err != nil { - return newInternalServerServiceError(err, "Failed to convert attendance data to CSV format") - } - - docLabel := strings.TrimSpace(FormatNullableString(user.DocID)) - if docLabel == "" { - docLabel = fmt.Sprintf("User%d", userID) - } - date := time.Now().Format("2006-01-02") - filename := fmt.Sprintf("Attendance-%s-%s.csv", docLabel, date) - - w.Header().Set("Content-Type", "text/csv") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.WriteHeader(http.StatusOK) - - writer := csv.NewWriter(w) - if err := writer.WriteAll(toCSV); err != nil { - return newInternalServerServiceError(err, "Failed to write CSV data") - } - - log.add("user_id", userID) - log.add("rows_exported", len(csvData)) - log.info("Exported resident attendance CSV") - - return nil -} diff --git a/backend/src/modules/programs/users_service.go b/backend/src/modules/programs/users_service.go deleted file mode 100644 index d0a8d0910..000000000 --- a/backend/src/modules/programs/users_service.go +++ /dev/null @@ -1,34 +0,0 @@ -package programs - -import ( - "context" - "fmt" - "math" -) - -type UsersService struct { - db *Store -} - -func NewUsersService(db *Store) *UsersService { - return &UsersService{db: db} -} - -func (svc *UsersService) GetWeeklyAttendanceTrend(ctx context.Context, userID, weeks int) ([]WeeklyAttendanceTrend, error) { - rows, err := svc.db.GetUserWeeklyAttendanceRows(ctx, userID, weeks) - if err != nil { - return nil, err - } - trends := make([]WeeklyAttendanceTrend, 0, len(rows)) - for _, r := range rows { - rate := 0.0 - if r.TotalCount > 0 { - rate = math.Round(r.PresentCount / r.TotalCount * 100) - } - trends = append(trends, WeeklyAttendanceTrend{ - Week: fmt.Sprintf("%s %d", r.WeekStart.Format("Jan"), r.WeekStart.Day()), - Rate: rate, - }) - } - return trends, nil -} diff --git a/backend/src/modules/programs/users_store.go b/backend/src/modules/programs/users_store.go deleted file mode 100644 index b804601e1..000000000 --- a/backend/src/modules/programs/users_store.go +++ /dev/null @@ -1,247 +0,0 @@ -package programs - -import ( - "context" -) - -func (db *Store) GetTransferProgramConflicts(ctx context.Context, id uint, transferFacilityId int) ([]ResidentTransferProgramConflicts, error) { - var programNames []ResidentTransferProgramConflicts - query := `with transfer_facility_programs as ( - select name from programs p - inner join facilities_programs fp on fp.program_id = p.id - and fp.facility_id = ? - where p.is_active = true - ) - select cl.name as class_name, p.name as program_name from program_class_enrollments pce - inner join users u on u.id = pce.user_id - and u.id = ? - inner join program_class_cohorts pc on pc.id = pce.cohort_id - and pc.facility_id = u.facility_id - inner join program_classes cl on cl.id = pc.class_id - inner join programs p on p.id = pc.program_id - where enrollment_status = 'Enrolled' - and p.name not in (select name from transfer_facility_programs)` - if err := db.WithContext(ctx).Raw(query, transferFacilityId, id).Scan(&programNames).Error; err != nil { - return nil, err - } - return programNames, nil -} - -// GetEnrolledResidentIDsForClass is the users enrolled in a class right now. -func (db *Store) GetEnrolledResidentIDsForClass(ctx context.Context, classID int) ([]uint, error) { - var ids []uint - if err := db.WithContext(ctx).Table("program_class_enrollments"). - Where("cohort_id = ? AND enrollment_status = 'Enrolled'", classID). - Pluck("user_id", &ids).Error; err != nil { - return nil, newGetRecordsDBError(err, "program_class_enrollments") - } - return ids, nil -} - -// GetEligibleResidentIDsForClass is the active residents at a class's facility, -// which offers its program, who have no enrollment in the class. -func (db *Store) GetEligibleResidentIDsForClass(ctx context.Context, classID int) ([]uint, error) { - var ids []uint - if err := db.WithContext(ctx).Table("users"). - Joins("LEFT JOIN program_class_enrollments pse ON users.id = pse.user_id AND pse.cohort_id = ?", classID). - Joins("JOIN facilities_programs fp ON users.facility_id = fp.facility_id"). - Joins("JOIN program_class_cohorts c ON c.program_id = fp.program_id AND c.id = ?", classID). - Where("pse.user_id IS NULL"). //not enrolled in class - Where("users.role = 'student'"). - Where("users.facility_id = c.facility_id"). - Where("users.deactivated_at IS NULL"). - Pluck("users.id", &ids).Error; err != nil { - return nil, newGetRecordsDBError(err, "users") - } - return ids, nil -} - -func (db *Store) GetChangeLogEntries(args *QueryContext, tableName string, refID int, categories []string) ([]ActivityHistoryResponse, error) { - history := make([]ActivityHistoryResponse, 0, args.PerPage) - - programCategoryFields := map[string][]string{ - "info": {"name", "description", "program"}, - "status": {"status", "is_active", "archived_at"}, - "settings": {"credit_type", "credit_hours", "funding_type", "program_type"}, - } - - classCategoryFields := map[string][]string{ - "info": {"name", "description", "instructor_name", "class"}, - "status": {"status", "archived_at"}, - "schedule": {"start_date", "end_date", "start_dt", "end_dt", "meeting_days", "meeting_times", "event_rescheduled_series", "event_rescheduled", "event_cancelled", "event_restored", "event_substitute_instructor", "event_room_changed"}, - "settings": {"capacity", "location", "credit_hours"}, - } - - //added criteria field_name != 'facility_id to skip these records from being a part of the result set, this may be temporary? - tx := db.WithContext(args.Ctx). - Table("change_log_entries cle"). - Select(`cle.created_at, cle.user_id, - users.username AS admin_username, - 'progclass_history' AS action, - cle.*`). - Joins("inner join users on cle.user_id = users.id"). - Where("cle.table_name = ? and cle.parent_ref_id = ? and field_name != 'facility_id'", tableName, refID) - - if len(categories) > 0 { - var fields []string - var categoryFields map[string][]string - - if tableName == "programs" { - categoryFields = programCategoryFields - } else { - categoryFields = classCategoryFields - } - - for _, category := range categories { - if categoryFields[category] != nil { - fields = append(fields, categoryFields[category]...) - } - } - if len(fields) > 0 { - tx = tx.Where("cle.field_name IN ?", fields) - } - } - - if err := tx.Count(&args.Total).Error; err != nil { - return nil, newGetRecordsDBError(err, "change_log_entries") - } - if err := tx.Order("cle.created_at desc"). - Offset(args.CalcOffset()). - Limit(args.PerPage). - Scan(&history).Error; err != nil { - return nil, newGetRecordsDBError(err, "change_log_entries") - } - return history, nil -} - -func (db *Store) GetUserProgramInfo(args *QueryContext, userId int) ([]ResidentProgramClassInfo, error) { - userEnrollments := make([]ResidentProgramClassInfo, 0, args.PerPage) - base := db.WithContext(args.Ctx). - Table("program_class_enrollments AS pce"). - Select(` - pce.id AS enrollment_id, - pce.enrollment_status AS enrollment_status, - p.name AS program_name, - p.id AS program_id, - cl.name AS class_name, - pc.status AS status, - pc.start_dt AS start_date, - pc.end_dt AS end_date, - pc.id AS cohort_id, - pce.updated_at, - pce.created_at, - (SELECT ARRAY_TO_STRING(ARRAY_AGG(DISTINCT pct.credit_type), ', ') - FROM program_credit_types pct WHERE pct.program_id = p.id) AS credit_types, - - ROUND(COALESCE(SUM( - CASE - WHEN pcea.attendance_status = 'present' THEN 1 - WHEN pcea.attendance_status = 'partial' THEN LEAST( - COALESCE(pcea.minutes_attended, pcea.scheduled_minutes, 0)::numeric / - NULLIF(COALESCE(pcea.scheduled_minutes, pcea.minutes_attended, 0), 0), - 1 - ) - ELSE 0 - END - ), 0))::int AS present_attendance, - - COALESCE(SUM( - CASE WHEN pcea.attendance_status NOT IN ('present','partial') THEN 1 ELSE 0 END - ), 0) AS absent_attendance, pce.change_reason, - STRING_AGG(DISTINCT e.recurrence_rule, '|') AS recurrence_rule - `). - Joins("INNER JOIN program_class_cohorts pc ON pc.id = pce.cohort_id"). - // A cohort has no name; every displayed class name comes from the class tier, so - // this join is required for cl.name in the SELECT -- not optional enrichment. - Joins("INNER JOIN program_classes cl ON cl.id = pc.class_id"). - Joins("INNER JOIN programs p ON p.id = pc.program_id"). - Joins("INNER JOIN program_class_events e ON e.cohort_id = pc.id"). - Joins( - `LEFT JOIN program_class_event_attendance pcea - ON pcea.event_id = e.id - AND pcea.user_id = ? - AND pcea.deleted_at IS NULL`, - userId, - ). - Where("pce.user_id = ?", userId). - Group(` - pce.id, pce.enrollment_status, - p.name, p.id, - cl.name, pc.status, pc.start_dt, pc.end_dt, pc.id, pce.updated_at, - pce.created_at, pce.change_reason`).Order(args.OrderClause("pce.created_at desc")) - - if !args.All { - if err := base.Count(&args.Total).Error; err != nil { - return nil, newDBError(err, "program_class_enrollments") - } - if err := base.Limit(args.PerPage).Offset(args.CalcOffset()).Scan(&userEnrollments).Error; err != nil { - return nil, newDBError(err, "program_class_enrollments apply pagination") - } - } else { - if err := base.Find(&userEnrollments).Error; err != nil { - return nil, newDBError(err, "program_class_enrollments") - } - - } - return userEnrollments, nil -} - -func (db *Store) GetUserWeeklyAttendanceRows(ctx context.Context, userID, weeks int) ([]WeeklyAttendanceRow, error) { - var rows []WeeklyAttendanceRow - query := ` - SELECT DATE_TRUNC('week', date::date) AS week_start, - COALESCE(SUM(CASE WHEN attendance_status IN ('present','partial') THEN 1 ELSE 0 END), 0) AS present_count, - COUNT(*) AS total_count - FROM program_class_event_attendance - WHERE user_id = ? AND date::date >= NOW() - INTERVAL '1 week' * ? AND deleted_at IS NULL - GROUP BY week_start - ORDER BY week_start ASC` - if err := db.WithContext(ctx).Raw(query, userID, weeks).Scan(&rows).Error; err != nil { - return nil, newGetRecordsDBError(err, "weekly_attendance_trend") - } - return rows, nil -} - -func (db *Store) GetResidentAttendanceCSVData(ctx context.Context, userID uint, facilityID uint, all bool, classID *uint) ([]ResidentAttendanceCSVData, error) { - var csvData []ResidentAttendanceCSVData - - query := ` - SELECT - p.name AS program_name, - cl.name AS class_name, - pcea.date AS session_date, - pcea.attendance_status, - COALESCE(pcea.note, '') AS note - FROM program_class_event_attendance pcea - JOIN program_class_events pce ON pce.id = pcea.event_id - JOIN program_class_cohorts pc ON pc.id = pce.cohort_id - JOIN program_classes cl ON cl.id = pc.class_id - JOIN programs p ON p.id = pc.program_id - JOIN program_class_enrollments e ON e.cohort_id = pc.id AND e.user_id = pcea.user_id - JOIN users u ON u.id = pcea.user_id - WHERE pcea.user_id = ? - AND pcea.deleted_at IS NULL - AND (e.enrolled_at IS NULL OR CAST(DATE(e.enrolled_at) AS TEXT) <= pcea.date) - AND (e.enrollment_ended_at IS NULL OR CAST(DATE(e.enrollment_ended_at) AS TEXT) >= pcea.date) - ` - - args := []any{userID} - - if !all { - query += " AND u.facility_id = ?" - args = append(args, facilityID) - } - - if classID != nil { - query += " AND pc.id = ?" - args = append(args, *classID) - } - - query += " ORDER BY p.name ASC, cl.name ASC, pcea.date ASC" - - if err := db.WithContext(ctx).Raw(query, args...).Scan(&csvData).Error; err != nil { - return nil, newGetRecordsDBError(err, "resident attendance CSV data") - } - - return csvData, nil -} diff --git a/backend/src/modules/programs/utils.go b/backend/src/modules/programs/utils.go deleted file mode 100644 index a6662be4f..000000000 --- a/backend/src/modules/programs/utils.go +++ /dev/null @@ -1,54 +0,0 @@ -package programs - -import ( - "strings" - "time" -) - -func FilterMap[T any](ss []T, test func(T) bool) (ret []T) { - for _, s := range ss { - if test(s) { - ret = append(ret, s) - } - } - return -} - -func IterMap[T any, E any](fun func(T) E, arr []T) []E { - applied := []E{} - for _, item := range arr { - applied = append(applied, fun(item)) - } - return applied -} - -func GetUntilDateFromRule(rRule string) string { - for _, rRulePart := range strings.Split(rRule, ";") { - rRule, hasUntil := strings.CutPrefix(rRulePart, "UNTIL=") - if hasUntil { - return rRule - } - } - return "" -} - -func ReplaceOrAddUntilDate(rRule, untilDate string) string { - rRuleParts := strings.Split(rRule, ";") - untilExists := false - for i, part := range rRuleParts { - if strings.HasPrefix(part, "UNTIL=") { - rRuleParts[i] = "UNTIL=" + untilDate - untilExists = true - break - } - } - if !untilExists { - rRuleParts = append(rRuleParts, "UNTIL="+untilDate) - } - return strings.Join(rRuleParts, ";") -} - -func FormatDateForUntil(t time.Time) string { - endOfDay := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC) - return endOfDay.Format("20060102T150405Z") -} diff --git a/backend/src/modules/registry/registry.go b/backend/src/modules/registry/registry.go index 8c1d01d70..e87d7f20b 100644 --- a/backend/src/modules/registry/registry.go +++ b/backend/src/modules/registry/registry.go @@ -1,7 +1,7 @@ // Package registry is the one list of feature modules. The server and the // migrations CLI both use it, so adding a module means adding it here only. -// It also hands core each module's api, so core never imports a module's -// insides. +// It also builds the Provider Platforms client, which the server and the +// scheduler share. package registry import ( @@ -10,20 +10,15 @@ import ( "github.com/UnlockedLabs/unlocked-core/backend/src/modules/learningrecord" "github.com/UnlockedLabs/unlocked-core/backend/src/modules/module" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - programsapi "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs/api" - "github.com/UnlockedLabs/unlocked-contracts/coreapi" "github.com/UnlockedLabs/unlocked-contracts/providerplatforms" - "gorm.io/gorm" ) // All returns every feature module, in the order their routes and jobs are -// registered and their migrations run. Provider Platforms is its own service -// since EN-206, so it isn't here. +// registered and their migrations run. Provider Platforms and Programs are +// their own services since EN-206 and EN-207, so they aren't here. func All() []module.Module { return []module.Module{ - programsModule(), learningrecord.Module{}, } } @@ -46,32 +41,3 @@ func ProviderPlatformsToken() string { return os.Getenv("PROVIDER_PLATFORMS_TOKE func ProviderPlatforms() providerplatforms.ProviderPlatforms { return providerplatforms.NewClient(ProviderPlatformsURL(), ProviderPlatformsToken(), http.DefaultClient) } - -// Programs is Programs' answers to core, on the connection db gives the -// Programs module (database.ModuleDBs.For). -func Programs(db func(name string) *gorm.DB) programsapi.Programs { - return programs.NewAPI(db(programs.Module{}.Name())) -} - -// ProgramsInTx runs fn with Programs' answers inside core's transaction tx, as -// Programs' own connection runs (module.RunAs), so its writes commit or roll -// back with core's. -func ProgramsInTx(tx *gorm.DB, fn func(programsapi.Programs) error) error { - return module.RunAs(tx, programsModule(), func(tx *gorm.DB) error { - return fn(programs.NewAPI(tx)) - }) -} - -// LiveProviders is Programs' Canvas and Essential Education code, on Programs' -// own connection, for the Provider Platforms service to call. -func LiveProviders(deps module.Deps) coreapi.LiveProviders { - m := programsModule() - deps.DB = deps.ModuleDBs(m.Name()) - return m.LiveProviders(deps) -} - -// programsModule is the Programs module, which asks the Provider Platforms -// service about providers. -func programsModule() programs.Module { - return programs.Module{ProviderPlatforms: ProviderPlatforms()} -} diff --git a/backend/src/modules/registry/registry_test.go b/backend/src/modules/registry/registry_test.go index eb5521508..97b23ad15 100644 --- a/backend/src/modules/registry/registry_test.go +++ b/backend/src/modules/registry/registry_test.go @@ -25,13 +25,13 @@ func TestAllModules(t *testing.T) { t.Errorf("module %q has no migrations folder: %v", name, err) } } - // Open Content (EN-205) and Provider Platforms (EN-206) run as their own - // services. - if len(seen) != 2 { - t.Errorf("got %d modules, want 2: Programs and Learning Record", len(seen)) + // Open Content (EN-205), Provider Platforms (EN-206) and Programs (EN-207) + // run as their own services. + if len(seen) != 1 { + t.Errorf("got %d modules, want 1: Learning Record", len(seen)) } - if seen["providerplatforms"] || seen["opencontent"] { - t.Error("Open Content and Provider Platforms run as their own services, not as core modules") + if seen["providerplatforms"] || seen["opencontent"] || seen["programs"] { + t.Error("Open Content, Provider Platforms and Programs run as their own services, not as core modules") } } diff --git a/backend/tests/integration/attendance_reason_test.go b/backend/tests/integration/attendance_reason_test.go deleted file mode 100644 index 664a6e95b..000000000 --- a/backend/tests/integration/attendance_reason_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestAttendanceReasons(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("testadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - student, err := env.CreateTestUser("student", models.Student, facility.ID, "STU001") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "reason") - require.NoError(t, err) - - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - event, err := env.CreateTestEvent(class.ID, "", instructor.ID) - require.NoError(t, err) - - _, err = env.CreateTestEnrollment(class.ID, student.ID, programs.Enrolled) - require.NoError(t, err) - - date := time.Now().Format("2006-01-02") - - t.Run("Save attendance with Medical reason", func(t *testing.T) { - attendanceData := []programs.ProgramClassEventAttendance{ - { - UserID: student.ID, - Date: date, - AttendanceStatus: programs.Absent_Excused, - ReasonCategory: models.StringPtr("Medical"), - Note: nil, - }, - } - - NewRequest[any](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance", class.ID, event.ID), - attendanceData). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: facilityAdmin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusOK) - - // Verify in DB - var stored programs.ProgramClassEventAttendance - err := env.DB.Where("user_id = ? AND event_id = ? AND date = ?", student.ID, event.ID, date).First(&stored).Error - require.NoError(t, err) - require.Equal(t, "Medical", models.FormatNullableString(stored.ReasonCategory)) - require.Nil(t, stored.Note) - }) - - t.Run("Save attendance with Other reason and Note", func(t *testing.T) { - attendanceData := []programs.ProgramClassEventAttendance{ - { - UserID: student.ID, - Date: date, - AttendanceStatus: programs.Absent_Unexcused, - ReasonCategory: models.StringPtr("Other"), - Note: models.StringPtr("Something specific"), - }, - } - - NewRequest[any](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance", class.ID, event.ID), - attendanceData). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: facilityAdmin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusOK) - - // Verify in DB - var stored programs.ProgramClassEventAttendance - err := env.DB.Where("user_id = ? AND event_id = ? AND date = ?", student.ID, event.ID, date).First(&stored).Error - require.NoError(t, err) - require.Equal(t, "Other", models.FormatNullableString(stored.ReasonCategory)) - require.Equal(t, "Something specific", models.FormatNullableString(stored.Note)) - }) - - t.Run("Update to Present clears reason and note", func(t *testing.T) { - // First set it to something else (should be covered by previous test, but good to be explicit if run in isolation) - // But here we just update the existing record from previous step - attendanceData := []programs.ProgramClassEventAttendance{ - { - UserID: student.ID, - Date: date, - AttendanceStatus: programs.Present, - ReasonCategory: models.StringPtr(""), // Frontend sends empty string - Note: models.StringPtr(""), - }, - } - - NewRequest[any](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance", class.ID, event.ID), - attendanceData). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: facilityAdmin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusOK) - - // Verify in DB - var stored programs.ProgramClassEventAttendance - err := env.DB.Where("user_id = ? AND event_id = ? AND date = ?", student.ID, event.ID, date).First(&stored).Error - require.NoError(t, err) - require.Equal(t, programs.Present, stored.AttendanceStatus) - // Note: Depending on how the update works, it might not clear fields if they are not specified, - // but the handler receives the whole struct. - // The `LogUserAttendance` uses `clause.OnConflict` with `DoUpdates`. - // Empty strings are stored as NULL. - require.Nil(t, stored.ReasonCategory) - require.Nil(t, stored.Note) - }) - - t.Run("Update from Present back to Absent saves reason", func(t *testing.T) { - attendanceData := []programs.ProgramClassEventAttendance{ - { - UserID: student.ID, - Date: date, - AttendanceStatus: programs.Absent_Excused, - ReasonCategory: models.StringPtr("Disciplinary"), - Note: nil, - }, - } - - NewRequest[any](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance", class.ID, event.ID), - attendanceData). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: facilityAdmin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusOK) - - // Verify in DB - var stored programs.ProgramClassEventAttendance - err := env.DB.Where("user_id = ? AND event_id = ? AND date = ?", student.ID, event.ID, date).First(&stored).Error - require.NoError(t, err) - require.Equal(t, programs.Absent_Excused, stored.AttendanceStatus) - require.Equal(t, "Disciplinary", models.FormatNullableString(stored.ReasonCategory)) - }) -} diff --git a/backend/tests/integration/attendance_validation_test.go b/backend/tests/integration/attendance_validation_test.go deleted file mode 100644 index 6a94716b5..000000000 --- a/backend/tests/integration/attendance_validation_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package integration - -import ( - "context" - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// - POST /api/program-classes/{cohort_id}/events/{event_id}/attendance -// - DELETE /api/program-classes/{cohort_id}/events/{event_id}/attendance/{user_id} -func TestAttendanceValidation(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("testadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - enrolledStudent, err := env.CreateTestUser("enrolledstudent", models.Student, facility.ID, "STU001") - require.NoError(t, err) - - unenrolledStudent, err := env.CreateTestUser("unenrolledstudent", models.Student, facility.ID, "STU002") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "validation") - require.NoError(t, err) - - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - event, err := env.CreateTestEvent(class.ID, "", instructor.ID) - require.NoError(t, err) - - _, err = env.CreateTestEnrollment(class.ID, enrolledStudent.ID, programs.Enrolled) - require.NoError(t, err) - - cancelledDate := time.Now().AddDate(0, 0, -1).Format("2006-01-02") // yesterday (past date) - _, err = env.CreateTestEventOverride(event.ID, cancelledDate, true, "cancelled") - require.NoError(t, err) - - t.Run("POST Attendance Validation", func(t *testing.T) { - testPOSTAttendanceValidation(t, env, facilityAdmin, class, event, enrolledStudent, unenrolledStudent, cancelledDate) - }) - - t.Run("DELETE Attendance Validation", func(t *testing.T) { - testDELETEAttendanceValidation(t, env, facilityAdmin, class, event, enrolledStudent, unenrolledStudent, cancelledDate) - }) -} - -func testPOSTAttendanceValidation(t *testing.T, env *TestEnv, admin *models.User, class *programs.ProgramClassCohort, event *programs.ProgramClassEvent, enrolledStudent, unenrolledStudent *models.User, cancelledDate string) { - validDate := time.Now().AddDate(0, 0, -2).Format("2006-01-02") // day before yesterday - - t.Run("Reject attendance for cancelled date", func(t *testing.T) { - attendanceData := []programs.ProgramClassEventAttendance{ - { - UserID: enrolledStudent.ID, - Date: cancelledDate, - AttendanceStatus: programs.Present, - Note: models.StringPtr("Test attendance"), - }, - } - - NewRequest[any](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance", class.ID, event.ID), - attendanceData). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: admin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusConflict). - ExpectBodyContains("cannot record attendance for cancelled class date") - }) - - t.Run("Allow attendance for historically enrolled user", func(t *testing.T) { - attendanceData := []programs.ProgramClassEventAttendance{ - { - UserID: unenrolledStudent.ID, - Date: validDate, - AttendanceStatus: programs.Present, - Note: models.StringPtr("Test attendance"), - }, - } - - NewRequest[any](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance", class.ID, event.ID), - attendanceData). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: admin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusOK). - ExpectBodyContains("Attendance updated") - }) - - t.Run("Accept valid attendance for enrolled user on valid date", func(t *testing.T) { - attendanceData := []programs.ProgramClassEventAttendance{ - { - UserID: enrolledStudent.ID, - Date: validDate, - AttendanceStatus: programs.Present, - Note: models.StringPtr("Valid attendance"), - }, - } - - NewRequest[any](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance", class.ID, event.ID), - attendanceData). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: admin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusOK). - ExpectBodyContains("Attendance updated") - }) - - t.Run("Reject multiple users with mixed enrollment status", func(t *testing.T) { - attendanceData := []programs.ProgramClassEventAttendance{ - { - UserID: enrolledStudent.ID, - Date: validDate, - AttendanceStatus: programs.Present, - Note: models.StringPtr("Enrolled student"), - }, - { - UserID: unenrolledStudent.ID, - Date: validDate, - AttendanceStatus: programs.Present, - Note: models.StringPtr("Unenrolled student"), - }, - } - - NewRequest[any](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance", class.ID, event.ID), - attendanceData). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: admin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusOK). - ExpectBodyContains("Attendance updated") - }) -} - -func testDELETEAttendanceValidation(t *testing.T, env *TestEnv, admin *models.User, class *programs.ProgramClassCohort, event *programs.ProgramClassEvent, enrolledStudent, unenrolledStudent *models.User, cancelledDate string) { - validDate := time.Now().AddDate(0, 0, -2).Format("2006-01-02") // day before yesterday - - attendanceData := []programs.ProgramClassEventAttendance{ - { - EventID: event.ID, - UserID: enrolledStudent.ID, - Date: validDate, - AttendanceStatus: programs.Present, - Note: models.StringPtr("To be deleted"), - }, - } - err := env.Programs.LogUserAttendance(attendanceData, context.Background(), &admin.ID, class.ClassName) - require.NoError(t, err) - - t.Run("Reject delete attendance for cancelled date", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance/%d?date=%s", class.ID, event.ID, enrolledStudent.ID, cancelledDate), nil). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: admin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusConflict). - ExpectBodyContains("cannot delete attendance for cancelled class date") - }) - - t.Run("Reject delete attendance for unenrolled user", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance/%d?date=%s", class.ID, event.ID, unenrolledStudent.ID, validDate), nil). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: admin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusBadRequest). - ExpectBodyContains("user is not enrolled in class") - }) - - t.Run("Accept delete attendance for enrolled user on valid date", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d/events/%d/attendance/%d?date=%s", class.ID, event.ID, enrolledStudent.ID, validDate), nil). - WithTestClaims(&handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: admin.FacilityID, - }). - Do(). - ExpectStatus(http.StatusNoContent) - }) -} diff --git a/backend/tests/integration/audit_tracking_test.go b/backend/tests/integration/audit_tracking_test.go deleted file mode 100644 index f13da796e..000000000 --- a/backend/tests/integration/audit_tracking_test.go +++ /dev/null @@ -1,351 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/UnlockedLabs/unlocked-contracts/identity" -) - -func TestAuditFieldPopulation(t *testing.T) { - env := SetupTestEnv(t) - defer env.TestServer.Close() - defer env.Cancel() - - adminUser := models.User{ - Username: "audit_admin", - NameFirst: "Audit", - NameLast: "Admin", - Email: "audit_admin@unlocked.v2", - Role: models.SystemAdmin, - FacilityID: 1, - } - if err := env.DB.Create(&adminUser).Error; err != nil { - t.Fatalf("Failed to create test admin user: %v", err) - } - - programData := map[string]interface{}{ - "name": "Test Audit Program", - "description": "Program for testing audit fields", - "funding_type": "Federal_Grants", - "is_active": true, - "program_types": []programs.ProgramType{ - {ProgramType: programs.Educational, ProgramID: 0}, - }, - "credit_types": []programs.ProgramCreditType{ - {CreditType: programs.Completion, ProgramID: 0}, - }, - "facilities": []uint{1}, - } - - resp := NewRequest[map[string]interface{}](env.Client, t, "POST", "/api/programs", programData). - WithTestClaims(map[string]interface{}{ - "user_id": adminUser.ID, - "username": adminUser.Username, - "role": adminUser.Role, - "facility_id": adminUser.FacilityID, - }). - Do() - - if resp.resp.StatusCode != http.StatusCreated { - t.Fatalf("Expected status 201, got %d", resp.resp.StatusCode) - } - - responseData := resp.GetData() - createdProgramID := uint(responseData["id"].(float64)) - - var createdProgram programs.Program - if err := env.DB.First(&createdProgram, createdProgramID).Error; err != nil { - t.Fatalf("Failed to query created program: %v", err) - } - - if models.DerefUint(createdProgram.CreateUserID) == 0 { - t.Error("CreateUserID should be set after creation") - } - if models.DerefUint(createdProgram.CreateUserID) != adminUser.ID { - t.Errorf("Expected CreateUserID %d, got %d", adminUser.ID, models.DerefUint(createdProgram.CreateUserID)) - } - if models.DerefUint(createdProgram.UpdateUserID) != 0 { - t.Errorf("UpdateUserID should be nil/zero on initial creation, got %d", models.DerefUint(createdProgram.UpdateUserID)) - } - - updateData := map[string]interface{}{ - "name": "Updated Audit Program", - "description": "Updated program description", - } - - updateResp := NewRequest[map[string]interface{}](env.Client, t, "PATCH", fmt.Sprintf("/api/programs/%d", createdProgramID), updateData). - WithTestClaims(map[string]interface{}{ - "user_id": adminUser.ID, - "username": adminUser.Username, - "role": adminUser.Role, - "facility_id": adminUser.FacilityID, - }). - Do() - - if updateResp.resp.StatusCode != http.StatusOK { - t.Fatalf("Expected status 200, got %d", updateResp.resp.StatusCode) - } - - var updatedProgram programs.Program - if err := env.DB.First(&updatedProgram, createdProgramID).Error; err != nil { - t.Fatalf("Failed to query updated program: %v", err) - } - - if models.DerefUint(updatedProgram.CreateUserID) != adminUser.ID { - t.Errorf("CreateUserID should not change on update. Expected %d, got %d", adminUser.ID, models.DerefUint(updatedProgram.CreateUserID)) - } - if models.DerefUint(updatedProgram.UpdateUserID) != adminUser.ID { - t.Errorf("UpdateUserID should be set to current user. Expected %d, got %d", adminUser.ID, models.DerefUint(updatedProgram.UpdateUserID)) - } - - if updatedProgram.Name != "Updated Audit Program" { - t.Errorf("Expected program name to be updated, got: %s", updatedProgram.Name) - } - if updatedProgram.Description != "Updated program description" { - t.Errorf("Expected program description to be updated, got: %s", updatedProgram.Description) - } -} - -func TestAuditFieldsWithDifferentUsers(t *testing.T) { - env := SetupTestEnv(t) - defer env.TestServer.Close() - defer env.Cancel() - - firstUser := models.User{ - Username: "first_user", - NameFirst: "First", - NameLast: "User", - Email: "first_user@unlocked.v2", - Role: models.DepartmentAdmin, - FacilityID: 1, - } - if err := env.DB.Create(&firstUser).Error; err != nil { - t.Fatalf("Failed to create first user: %v", err) - } - - secondUser := models.User{ - Username: "second_user", - NameFirst: "Second", - NameLast: "User", - Email: "second_user@unlocked.v2", - Role: models.DepartmentAdmin, - FacilityID: 1, - } - if err := env.DB.Create(&secondUser).Error; err != nil { - t.Fatalf("Failed to create second user: %v", err) - } - - programData := map[string]interface{}{ - "name": "Multi User Test Program", - "description": "Testing audit with multiple users", - "funding_type": "State_Grants", - "is_active": true, - "program_types": []programs.ProgramType{ - {ProgramType: programs.Vocational, ProgramID: 0}, - }, - "facilities": []uint{1}, - } - - createResp := NewRequest[map[string]interface{}](env.Client, t, "POST", "/api/programs", programData). - WithTestClaims(map[string]interface{}{ - "user_id": firstUser.ID, - "username": firstUser.Username, - "role": firstUser.Role, - "facility_id": firstUser.FacilityID, - }). - Do() - - if createResp.resp.StatusCode != http.StatusCreated { - t.Fatalf("Failed to create program with first user, status: %d", createResp.resp.StatusCode) - } - - createdProgramData := createResp.GetData() - createdProgramID := uint(createdProgramData["id"].(float64)) - - var createdProgram programs.Program - if err := env.DB.First(&createdProgram, createdProgramID).Error; err != nil { - t.Fatalf("Failed to query created program: %v", err) - } - - if models.DerefUint(createdProgram.CreateUserID) != firstUser.ID { - t.Errorf("Expected CreateUserID %d (first user), got %d", firstUser.ID, createdProgram.CreateUserID) - } - if models.DerefUint(createdProgram.UpdateUserID) != 0 { - t.Errorf("Expected UpdateUserID to be nil/zero on create, got %d", createdProgram.UpdateUserID) - } - - updateData := map[string]interface{}{ - "name": "Updated by Second User", - } - - updateResp := NewRequest[map[string]interface{}](env.Client, t, "PATCH", fmt.Sprintf("/api/programs/%d", createdProgram.ID), updateData). - WithTestClaims(map[string]interface{}{ - "user_id": secondUser.ID, - "username": secondUser.Username, - "role": secondUser.Role, - "facility_id": secondUser.FacilityID, - }). - Do() - - if updateResp.resp.StatusCode != http.StatusOK { - t.Fatalf("Failed to update program with second user, status: %d", updateResp.resp.StatusCode) - } - - updatedProgramData := updateResp.GetData() - updatedProgramID := uint(updatedProgramData["id"].(float64)) - - var updatedProgram programs.Program - if err := env.DB.First(&updatedProgram, updatedProgramID).Error; err != nil { - t.Fatalf("Failed to query updated program: %v", err) - } - - if models.DerefUint(updatedProgram.CreateUserID) != firstUser.ID { - t.Errorf("CreateUserID should remain unchanged. Expected %d, got %d", firstUser.ID, updatedProgram.CreateUserID) - } - if models.DerefUint(updatedProgram.UpdateUserID) != secondUser.ID { - t.Errorf("UpdateUserID should be updated to second user. Expected %d, got %d", secondUser.ID, updatedProgram.UpdateUserID) - } -} - -func TestSystemBatchUserBackfill(t *testing.T) { - env := SetupTestEnv(t) - defer env.TestServer.Close() - defer env.Cancel() - - if env.DB.Name() == "sqlite" { - t.Skip("sqlite tests do not run migrations/backfill for system_batch") - } - - facility := models.Facility{ - Name: "Test Facility", - Timezone: "America/Chicago", - } - if err := env.DB.Create(&facility).Error; err != nil { - t.Fatalf("Failed to create facility: %v", err) - } - - var queriedFacility models.Facility - if err := env.DB.First(&queriedFacility, facility.ID).Error; err != nil { - t.Fatalf("Failed to query facility: %v", err) - } - - if models.DerefUint(queriedFacility.CreateUserID) == 0 { - t.Error("CreateUserID should be set by backfill/migration") - } - - var systemBatchUser models.User - if err := env.DB.Where("username = ?", "system_batch").First(&systemBatchUser).Error; err != nil { - t.Fatalf("System batch user should exist: %v", err) - } - - if models.DerefUint(queriedFacility.CreateUserID) != systemBatchUser.ID { - t.Errorf("Expected CreateUserID to be system batch user (%d), got %d", systemBatchUser.ID, queriedFacility.CreateUserID) - } - - if models.DerefUint(queriedFacility.UpdateUserID) != 0 { - t.Errorf("UpdateUserID should be NULL for backfilled records, got %d", queriedFacility.UpdateUserID) - } -} - -func TestAuditFieldConstraints(t *testing.T) { - env := SetupTestEnv(t) - defer env.TestServer.Close() - defer env.Cancel() - - if env.DB.Name() == "sqlite" { - t.Skip("sqlite tests do not enforce foreign key constraints in this setup") - } - - invalidUserProgram := programs.Program{ - Name: "Invalid User Program", - Description: "This should fail due to foreign key constraint", - FundingType: programs.Other, - IsActive: true, - DatabaseFields: programs.DatabaseFields{ - CreateUserID: models.UintPtr(999999), - UpdateUserID: models.UintPtr(999999), - }, - } - - err := env.DB.Create(&invalidUserProgram).Error - if err == nil { - t.Error("Expected foreign key constraint violation when using non-existent user ID") - } - - nullAuditProgram := programs.Program{ - Name: "System Operation Program", - Description: "Created by system operations", - FundingType: programs.Other, - IsActive: true, - } - - err = env.DB.Create(&nullAuditProgram).Error - if err != nil { - t.Errorf("Should allow creation with NULL audit fields: %v", err) - } -} - -func TestAuditFieldsPerformance(t *testing.T) { - env := SetupTestEnv(t) - defer env.TestServer.Close() - defer env.Cancel() - - testUser := models.User{ - Username: "perf_test_user", - NameFirst: "Performance", - NameLast: "Test", - Email: "perf_test@unlocked.v2", - Role: models.DepartmentAdmin, - FacilityID: 1, - } - if err := env.DB.Create(&testUser).Error; err != nil { - t.Fatalf("Failed to create test user: %v", err) - } - // Programs' models take the audit user from the caller's claims, as its - // routes have them. - env.Context = identity.NewContext(env.Context, &identity.Claims{UserID: testUser.ID}) - - for i := 0; i < 10; i++ { - program := programs.Program{ - Name: fmt.Sprintf("Performance Test Program %d", i), - Description: fmt.Sprintf("Testing performance of audit fields %d", i), - FundingType: programs.Other, - IsActive: true, - } - - err := env.DB.WithContext(env.Context).Model(&program).Create(&program).Error - if err != nil { - t.Fatalf("Failed to create program %d: %v", i, err) - } - - if models.DerefUint(program.CreateUserID) != testUser.ID { - t.Errorf("Program %d: Expected CreateUserID %d, got %d", i, testUser.ID, program.CreateUserID) - } - if models.DerefUint(program.UpdateUserID) != 0 { - t.Errorf("Program %d: Expected UpdateUserID to be nil/zero on create, got %d", i, program.UpdateUserID) - } - } - - var created []programs.Program - start := time.Now() - err := env.DB.Where("create_user_id = ?", testUser.ID).Find(&created).Error - duration := time.Since(start) - - if err != nil { - t.Fatalf("Failed to query programs by audit field: %v", err) - } - - if len(created) != 10 { - t.Errorf("Expected 10 programs, got %d", len(created)) - } - - if duration > 100*time.Millisecond { - t.Logf("Warning: Query took %v, consider checking audit field indexes", duration) - } -} diff --git a/backend/tests/integration/class_date_synchronization_test.go b/backend/tests/integration/class_date_synchronization_test.go deleted file mode 100644 index 0e78c5f12..000000000 --- a/backend/tests/integration/class_date_synchronization_test.go +++ /dev/null @@ -1,526 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// TestClassDateSynchronization tests that class start_dt and end_dt fields -// automatically synchronize with event schedules when events are modified. -func TestClassDateSynchronization(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("testadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - t.Run("Event override creation extends class end date", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Test Room 1"} - require.NoError(t, env.DB.Create(room).Error) - runTestOverrideCreationExtendsEndDate(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Event rescheduling extends class across multiple months", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Test Room 2"} - require.NoError(t, env.DB.Create(room).Error) - runTestEventReschedulingExtendsMultipleMonths(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Multiple overrides extend class end date appropriately", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Test Room 3"} - require.NoError(t, env.DB.Create(room).Error) - runTestMultipleOverridesExtendEndDate(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Cannot modify events for completed classes", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Test Room 4"} - require.NoError(t, env.DB.Create(room).Error) - runTestCannotModifyEventsForCompletedClasses(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Class start date updates when event moved earlier", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Test Room 5"} - require.NoError(t, env.DB.Create(room).Error) - runTestStartDateUpdatesWhenEventMovedEarlier(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Event cancellation affects class boundaries", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Test Room 6"} - require.NoError(t, env.DB.Create(room).Error) - runTestEventCancellationAffectsBoundaries(t, env, facility, facilityAdmin, program, room.ID) - }) -} - -func runTestOverrideCreationExtendsEndDate(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "override") - require.NoError(t, err) - - classStartDate := time.Date(2025, 11, 1, 0, 0, 0, 0, time.UTC) // November 1, 2025 - initialEndDate := time.Date(2025, 11, 30, 0, 0, 0, 0, time.UTC) // November 30, 2025 - creditHours := int64(3) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 15, - InstructorID: &instructor.ID, - Description: "Testing that override creation extends end date", - StartDt: classStartDate, - EndDt: &initialEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - classResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass := classResp.GetData() - require.Equal(t, initialEndDate, *createdClass.EndDt, "Initial end date should be November 30") - - eventPayload := map[string]interface{}{ - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20251101T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251130T000000Z", - "instructor_id": instructor.ID, - } - - resp := NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do() - - var createdEvent programs.ProgramClassEvent - err = env.DB.Where("cohort_id = ?", createdClass.ID).First(&createdEvent).Error - if err == nil { - t.Logf("Event successfully created in DB with ID: %d", createdEvent.ID) - } else { - t.Logf("No event found in DB: %v", err) - } - - if resp.resp.StatusCode == http.StatusCreated || resp.resp.StatusCode == http.StatusOK { - t.Logf("Event creation API successful") - if err == nil { - t.Logf("Event creation DB successful - Full success!") - } else { - t.Logf("API success but DB query failed") - } - } else { - t.Errorf("Event creation failed - Status: %d", resp.resp.StatusCode) - } - - event := &createdEvent - - overridePayload := []map[string]interface{}{ - { - "event_id": event.ID, - "cohort_id": createdClass.ID, - "override_rrule": "DTSTART:20251127T180000Z\nRRULE:FREQ=DAILY;COUNT=1", - "duration": "2h", - "room_id": roomID, - "is_cancelled": true, - "reason": "Thanksgiving reschedule", - }, - { - "event_id": event.ID, - "cohort_id": createdClass.ID, - "override_rrule": "DTSTART:20251202T180000Z\nRRULE:FREQ=DAILY;COUNT=1", - "duration": "2h", - "room_id": roomID, - "is_cancelled": false, - "reason": "Post-Thanksgiving session", - }, - } - - NewRequest[any](env.Client, t, http.MethodPut, fmt.Sprintf("/api/program-classes/%d/events/%d", createdClass.ID, event.ID), overridePayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedClassResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d", createdClass.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedClass := updatedClassResp.GetData() - expectedEndDate := time.Date(2025, 12, 2, 18, 0, 0, 0, time.UTC) // December 2, 2025 at 6 PM UTC (when override event occurs) - require.Equal(t, expectedEndDate, *updatedClass.EndDt, "Class end date should be extended to December 2 at 6 PM UTC") - require.True(t, updatedClass.EndDt.After(*createdClass.EndDt), "End date should be later than original November 30") -} - -func runTestEventReschedulingExtendsMultipleMonths(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "reschedule") - require.NoError(t, err) - classStartDate := time.Date(2025, 11, 1, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2025, 11, 30, 0, 0, 0, 0, time.UTC) - creditHours := int64(4) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 20, - InstructorID: &instructor.ID, - Description: "Testing multi-month extension via rescheduling", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - classResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass := classResp.GetData() - - eventPayload := map[string]interface{}{ - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20251104T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251130T000000Z", - "instructor_id": instructor.ID, - } - - eventResp := NewRequest[*programs.ProgramClassEvent](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - event := eventResp.GetData() - - reschedulePayload := map[string]interface{}{ - "event_series": map[string]interface{}{ - "id": event.ID, - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260203T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20260228T000000Z", - "instructor_id": instructor.ID, - }, - "closed_event_series": map[string]interface{}{ - "id": event.ID, - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20251104T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251130T000000Z", - "instructor_id": instructor.ID, - }, - } - - NewRequest[any](env.Client, t, http.MethodPut, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), reschedulePayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - extendedClassResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d", createdClass.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - extendedClass := extendedClassResp.GetData() - expectedExtendedEnd := time.Date(2025, 11, 30, 0, 0, 0, 0, time.UTC) // November 30, 2025 at 00:00:00 UTC (original class end date) - require.Equal(t, expectedExtendedEnd, *extendedClass.EndDt, "Class end date should remain at original November 30") -} - -func runTestMultipleOverridesExtendEndDate(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "multiple") - require.NoError(t, err) - classStartDate := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2025, 12, 20, 0, 0, 0, 0, time.UTC) - creditHours := int64(3) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 15, - InstructorID: &instructor.ID, - Description: "Testing multiple overrides extend end date", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - classResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass := classResp.GetData() - - eventPayload := map[string]interface{}{ - "duration": "1h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20251202T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20251220T000000Z", - "instructor_id": instructor.ID, - } - - eventResp := NewRequest[*programs.ProgramClassEvent](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - event := eventResp.GetData() - - overridePayload := []map[string]interface{}{ - { - "event_id": event.ID, - "cohort_id": createdClass.ID, - "override_rrule": "DTSTART:20260106T140000Z\nRRULE:FREQ=DAILY;COUNT=1", - "duration": "1h", - "room_id": roomID, - "is_cancelled": false, - "reason": "January extension", - }, - { - "event_id": event.ID, - "cohort_id": createdClass.ID, - "override_rrule": "DTSTART:20260203T140000Z\nRRULE:FREQ=DAILY;COUNT=1", - "duration": "1h", - "room_id": roomID, - "is_cancelled": false, - "reason": "February extension", - }, - { - "event_id": event.ID, - "cohort_id": createdClass.ID, - "override_rrule": "DTSTART:20260303T140000Z\nRRULE:FREQ=DAILY;COUNT=1", - "duration": "1h", - "room_id": roomID, - "is_cancelled": false, - "reason": "March extension", - }, - } - - NewRequest[any](env.Client, t, http.MethodPut, fmt.Sprintf("/api/program-classes/%d/events/%d", createdClass.ID, event.ID), overridePayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - extendedClassResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d", createdClass.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - extendedClass := extendedClassResp.GetData() - expectedExtendedEnd := time.Date(2025, 12, 20, 23, 59, 59, 0, time.UTC) - require.Equal(t, expectedExtendedEnd, *extendedClass.EndDt, "Class end date should reflect base event UNTIL date") -} - -func runTestCannotModifyEventsForCompletedClasses(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "completedevents") - require.NoError(t, err) - - class, err := env.CreateTestClass(program, facility, programs.Scheduled, &instructor.ID) - require.NoError(t, err) - - _, err = env.CreateTestEvent(class.ID, "", instructor.ID) - require.NoError(t, err) - - updateData := map[string]interface{}{ - "status": string(programs.Completed), - } - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - eventPayload := map[string]interface{}{ - "duration": "1h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20240101T100000Z\nRRULE:FREQ=WEEKLY;COUNT=4", - "instructor_id": instructor.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", class.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) - - overridePayload := map[string]interface{}{ - "override_rrule": "DTSTART:20241201T100000Z\nRRULE:FREQ=DAILY;COUNT=1", - "duration": "1h", - "room_id": roomID, - "is_cancelled": true, - "reason": "Testing completed class", - } - - NewRequest[any](env.Client, t, http.MethodPut, fmt.Sprintf("/api/program-classes/%d/events/%d", class.ID, 1), overridePayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) - - reschedulePayload := map[string]interface{}{ - "event_series": map[string]interface{}{ - "id": 1, - "date": "2024-12-01", - "start_time": "15:00", - "duration": "90m", - "room_id": roomID, - "recurrence_rule": "DTSTART:20241201T150000Z\nRRULE:FREQ=WEEKLY;COUNT=6", - }, - "closed_event_series": map[string]interface{}{ - "id": 1, - "date": "2024-11-01", - "start_time": "15:00", - "duration": "90m", - "room_id": roomID, - "recurrence_rule": "DTSTART:20241101T150000Z\nRRULE:FREQ=WEEKLY;COUNT=4", - }, - } - - NewRequest[any](env.Client, t, http.MethodPut, fmt.Sprintf("/api/program-classes/%d/events", class.ID), reschedulePayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) -} - -func runTestStartDateUpdatesWhenEventMovedEarlier(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "startdate") - require.NoError(t, err) - classStartDate := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor.ID, - Description: "Testing that start date updates when event moved earlier", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - classResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass := classResp.GetData() - require.Equal(t, classStartDate, createdClass.StartDt, "Initial start date should be December 15") - - eventPayload := map[string]interface{}{ - "duration": "1h30m", - "room_id": roomID, - "recurrence_rule": "DTSTART:20251220T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20260131T000000Z", - "instructor_id": instructor.ID, - } - - eventResp := NewRequest[*programs.ProgramClassEvent](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - event := eventResp.GetData() - - overridePayload := []map[string]interface{}{ - { - "event_id": event.ID, - "cohort_id": createdClass.ID, - "override_rrule": "DTSTART:20251210T180000Z\nRRULE:FREQ=DAILY;COUNT=1", - "duration": "1h30m", - "room_id": roomID, - "is_cancelled": false, - "reason": "Start date earlier adjustment", - }, - } - - NewRequest[any](env.Client, t, http.MethodPut, fmt.Sprintf("/api/program-classes/%d/events/%d", createdClass.ID, event.ID), overridePayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedClassResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d", createdClass.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedClass := updatedClassResp.GetData() - expectedStartDate := time.Date(2025, 12, 20, 18, 0, 0, 0, time.UTC) // Original RRULE start date - require.Equal(t, expectedStartDate, updatedClass.StartDt, "Class start date should sync with original RRULE start date when rescheduled events exist") -} - -func runTestEventCancellationAffectsBoundaries(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "cancellation") - require.NoError(t, err) - classStartDate := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 12, - InstructorID: &instructor.ID, - Description: "Testing that event cancellation affects boundaries", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - classResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass := classResp.GetData() - - eventPayload := map[string]interface{}{ - "duration": "1h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260108T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20260228T000000Z", - "instructor_id": instructor.ID, - } - - eventResp := NewRequest[*programs.ProgramClassEvent](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - event := eventResp.GetData() - - overridePayload := []map[string]interface{}{ - { - "event_id": event.ID, - "cohort_id": createdClass.ID, - "override_rrule": "DTSTART:20260201T180000Z\nRRULE:FREQ=DAILY;COUNT=8", - "duration": "1h", - "room_id": roomID, - "is_cancelled": true, - "reason": "February events cancelled", - }, - } - - NewRequest[any](env.Client, t, http.MethodPut, fmt.Sprintf("/api/program-classes/%d/events/%d", createdClass.ID, event.ID), overridePayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedClassResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d", createdClass.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedClass := updatedClassResp.GetData() - expectedNewEndDt := time.Date(2026, 2, 28, 23, 59, 59, 0, time.UTC) - require.Equal(t, expectedNewEndDt, *updatedClass.EndDt, "Class end date should reflect base event UNTIL date") -} diff --git a/backend/tests/integration/class_delete_cascade_test.go b/backend/tests/integration/class_delete_cascade_test.go deleted file mode 100644 index 0dfdf0c0d..000000000 --- a/backend/tests/integration/class_delete_cascade_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package integration - -import ( - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// Deleting the last cohort of a class must take the class with it, or the class tier -// accumulates rows no screen can reach: a class with no runs still appears in the create -// form's class dropdown and still occupies its name. -// -// The class is SOFT deleted, so these assertions must use Unscoped() to tell "hidden" -// apart from "gone" -- a plain First() cannot distinguish them. -func TestDeleteClassRemovesOrphanedParent(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Cascade Facility") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Cascade Program", programs.FundingType(programs.FederalGrants), - []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(program.ID, []uint{facility.ID})) - - instructor, err := env.CreateTestInstructor(facility.ID, "cascade") - require.NoError(t, err) - - t.Run("last cohort deletes its parent class", func(t *testing.T) { - cohort, err := env.CreateTestClass(program, facility, programs.Scheduled, &instructor.ID) - require.NoError(t, err) - classID := cohort.ClassID - require.NotZero(t, classID, "the auto-parent hook should have created a class") - - require.NoError(t, env.Programs.DeleteClass(int(cohort.ID))) - - var visible programs.ProgramClass - err = env.DB.Where("id = ?", classID).First(&visible).Error - require.Error(t, err, "class should no longer be visible once its last cohort is gone") - - var row programs.ProgramClass - require.NoError(t, env.DB.Unscoped().Where("id = ?", classID).First(&row).Error, - "class must be SOFT deleted, not hard deleted -- FKs point at this row") - require.True(t, row.DeletedAt.Valid, "deleted_at should be set on the parent class") - }) - - t.Run("a class with another cohort is left alone", func(t *testing.T) { - keep, err := env.CreateTestClass(program, facility, programs.Scheduled, &instructor.ID) - require.NoError(t, err) - classID := keep.ClassID - - // A second cohort under the SAME class -- this is what a multi-cohort class is. - sibling := &programs.ProgramClassCohort{ - ClassID: classID, - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor.ID, - Description: "sibling cohort", - StartDt: keep.StartDt, - Status: programs.Scheduled, - } - require.NoError(t, env.DB.Create(sibling).Error) - - require.NoError(t, env.Programs.DeleteClass(int(keep.ID))) - - var row programs.ProgramClass - require.NoError(t, env.DB.Where("id = ?", classID).First(&row).Error, - "class must survive while a sibling cohort still runs under it") - - // ...and removing that sibling too now takes the class. - require.NoError(t, env.Programs.DeleteClass(int(sibling.ID))) - require.Error(t, env.DB.Where("id = ?", classID).First(&programs.ProgramClass{}).Error, - "class should go once its final cohort is deleted") - }) - - t.Run("a class still holding a certificate is left alone", func(t *testing.T) { - cohort, err := env.CreateTestClass(program, facility, programs.Scheduled, &instructor.ID) - require.NoError(t, err) - classID := cohort.ClassID - - student, err := env.CreateTestUser("cascadestudent", models.Student, facility.ID, "CAS001") - require.NoError(t, err) - - // A certificate earned under this class, with no live cohort of its own -- the - // shape left behind when a sibling cohort was deleted earlier. Hiding the class - // would orphan the certificate's class link. - completion := programs.ClassCompletion{ - UserID: student.ID, - ClassID: &classID, - FacilityName: facility.Name, - CreditType: "Completion", - AdminEmail: "admin@example.com", - ProgramOwner: "owner", - ProgramName: program.Name, - ClassName: "Cascade Class", - } - require.NoError(t, env.DB.Create(&completion).Error) - - require.NoError(t, env.Programs.DeleteClass(int(cohort.ID))) - - var row programs.ProgramClass - require.NoError(t, env.DB.Where("id = ?", classID).First(&row).Error, - "a class referenced by a live certificate must not be removed") - }) -} diff --git a/backend/tests/integration/class_delete_guard_test.go b/backend/tests/integration/class_delete_guard_test.go deleted file mode 100644 index 2c1e7e613..000000000 --- a/backend/tests/integration/class_delete_guard_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/suite" -) - -type ClassDeleteGuardTestSuite struct { - suite.Suite - env *TestEnv -} - -func (suite *ClassDeleteGuardTestSuite) SetupSuite() { - suite.env = SetupTestEnv(suite.T()) -} - -func (suite *ClassDeleteGuardTestSuite) TearDownSuite() { - suite.env.CleanupTestEnv() -} - -func (suite *ClassDeleteGuardTestSuite) SetupTest() { - suite.env.DB.Exec("DELETE FROM program_class_event_attendance") - suite.env.DB.Exec("DELETE FROM program_class_events") - suite.env.DB.Exec("DELETE FROM program_class_enrollments") - suite.env.DB.Exec("DELETE FROM class_completions") - suite.env.DB.Exec("DELETE FROM change_log_entries") - suite.env.DB.Exec("DELETE FROM program_classes_history") - suite.env.DB.Exec("DELETE FROM program_class_cohorts") - suite.env.DB.Exec("DELETE FROM program_class_credit_types") - suite.env.DB.Exec("DELETE FROM program_classes") - suite.env.DB.Exec("DELETE FROM facilities_programs") - suite.env.DB.Exec("DELETE FROM programs") - suite.env.DB.Exec("DELETE FROM rooms") - suite.env.DB.Exec("DELETE FROM facilities") - suite.env.DB.Exec("DELETE FROM users WHERE role != 'system_admin'") -} - -// seedEmptyClass returns (admin, class) with an empty program_class — no children. -func (suite *ClassDeleteGuardTestSuite) seedEmptyClass() (*models.User, *programs.ProgramClassCohort) { - timestamp := fmt.Sprintf("%d", time.Now().UnixNano()) - facility := &models.Facility{Name: "Test Facility " + timestamp} - suite.env.DB.Create(facility) - - admin := &models.User{ - NameFirst: "Sys", NameLast: "Admin", - Username: "sys" + timestamp, Email: "sys" + timestamp + "@t.test", - Role: models.SystemAdmin, FacilityID: facility.ID, - } - suite.env.DB.Create(admin) - - program := &programs.Program{Name: "Prog " + timestamp, IsActive: true} - suite.env.DB.Create(program) - suite.env.DB.Create(&programs.FacilitiesPrograms{FacilityID: facility.ID, ProgramID: program.ID}) - - class := &programs.ProgramClassCohort{ - ProgramID: program.ID, FacilityID: facility.ID, - Status: programs.Scheduled, Capacity: 10, Description: "x", - } - suite.env.DB.Create(class) - return admin, class -} - -func (suite *ClassDeleteGuardTestSuite) TestDelete_EmptyClass_Succeeds() { - admin, class := suite.seedEmptyClass() - - NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d", class.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). - Do(). - ExpectStatus(http.StatusNoContent) -} - -func (suite *ClassDeleteGuardTestSuite) TestDelete_BlockedByEnrollment() { - admin, class := suite.seedEmptyClass() - - student := &models.User{ - NameFirst: "Stu", NameLast: "Dent", - Username: fmt.Sprintf("stu%d", time.Now().UnixNano()), - Email: fmt.Sprintf("stu%d@t.test", time.Now().UnixNano()), - Role: models.Student, FacilityID: class.FacilityID, - } - suite.env.DB.Create(student) - - enrollment := &programs.ProgramClassEnrollment{ - CohortID: class.ID, - UserID: student.ID, - EnrollmentStatus: programs.Enrolled, - } - suite.env.DB.Create(enrollment) - - resp := NewRequest[programs.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d", class.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). - Do(). - ExpectStatus(http.StatusConflict) - - got := resp.GetData() - suite.Equal(int64(1), got.Enrollments, "should report 1 blocking enrollment") - suite.Equal(int64(0), got.Events) - suite.Equal(int64(0), got.Completions) - - // Verify the class was NOT deleted. - var count int64 - suite.env.DB.Model(&programs.ProgramClassCohort{}).Where("id = ?", class.ID).Count(&count) - suite.Equal(int64(1), count, "class should still exist after blocked delete") -} - -func (suite *ClassDeleteGuardTestSuite) TestDelete_BlockedByCompletedEnrollment() { - admin, class := suite.seedEmptyClass() - student := suite.makeStudent(class.FacilityID) - - now := time.Now() - suite.env.DB.Create(&programs.ProgramClassEnrollment{ - CohortID: class.ID, - UserID: student.ID, - EnrollmentStatus: programs.EnrollmentCompleted, - EnrollmentEndedAt: &now, - }) - - resp := NewRequest[programs.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d", class.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). - Do(). - ExpectStatus(http.StatusConflict) - - got := resp.GetData() - suite.Equal(int64(1), got.Enrollments, "should report 1 blocking completed enrollment") - suite.Equal(int64(0), got.Events) - suite.Equal(int64(0), got.Completions) - - var count int64 - suite.env.DB.Model(&programs.ProgramClassCohort{}).Where("id = ?", class.ID).Count(&count) - suite.Equal(int64(1), count, "class should still exist after blocked delete") -} - -func (suite *ClassDeleteGuardTestSuite) TestDelete_BlockedByCancelledEnrollment() { - admin, class := suite.seedEmptyClass() - student := suite.makeStudent(class.FacilityID) - - now := time.Now() - suite.env.DB.Create(&programs.ProgramClassEnrollment{ - CohortID: class.ID, - UserID: student.ID, - EnrollmentStatus: programs.EnrollmentCancelled, - EnrollmentEndedAt: &now, - }) - - resp := NewRequest[programs.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d", class.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). - Do(). - ExpectStatus(http.StatusConflict) - - got := resp.GetData() - suite.Equal(int64(1), got.Enrollments, "should report 1 blocking cancelled enrollment") - suite.Equal(int64(0), got.Events) - suite.Equal(int64(0), got.Completions) - - var count int64 - suite.env.DB.Model(&programs.ProgramClassCohort{}).Where("id = ?", class.ID).Count(&count) - suite.Equal(int64(1), count, "class should still exist after blocked delete") -} - -func (suite *ClassDeleteGuardTestSuite) TestDelete_EventsOnly_Succeeds() { - admin, class := suite.seedEmptyClass() - - room := &programs.Room{FacilityID: class.FacilityID, Name: "101"} - suite.env.DB.Create(room) - instructor := &models.User{ - NameFirst: "Inst", NameLast: "Ructor", - Username: fmt.Sprintf("inst%d", time.Now().UnixNano()), - Email: fmt.Sprintf("inst%d@t.test", time.Now().UnixNano()), - Role: models.FacilityAdmin, FacilityID: class.FacilityID, - } - suite.env.DB.Create(instructor) - suite.env.DB.Create(&programs.ProgramClassEvent{ - CohortID: class.ID, - Duration: "1h0m0s", - RecurrenceRule: "DTSTART:20260302T093000Z\nRRULE:FREQ=WEEKLY;COUNT=1", - RoomID: &room.ID, - InstructorID: &instructor.ID, - }) - - NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d", class.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). - Do(). - ExpectStatus(http.StatusNoContent) - - var liveEvents int64 - suite.env.DB.Model(&programs.ProgramClassEvent{}). - Where("cohort_id = ? AND deleted_at IS NULL", class.ID). - Count(&liveEvents) - suite.Equal(int64(0), liveEvents, "events should be soft-deleted alongside the class") -} - -func (suite *ClassDeleteGuardTestSuite) TestDelete_BlockedByCompletion() { - admin, class := suite.seedEmptyClass() - student := suite.makeStudent(class.FacilityID) - - cohortID, classID, programID := class.ID, class.ClassID, class.ProgramID - suite.env.DB.Create(&programs.ClassCompletion{ - UserID: student.ID, - CohortID: &cohortID, - ClassID: &classID, - ProgramID: &programID, - FacilityName: "Test Facility", - CreditType: "Completion", - AdminEmail: "admin@t.test", - ProgramOwner: "Owner", - ProgramName: "Prog", - }) - - resp := NewRequest[programs.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d", class.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). - Do(). - ExpectStatus(http.StatusConflict) - - got := resp.GetData() - suite.Equal(int64(0), got.Enrollments) - suite.Equal(int64(0), got.Events) - suite.Equal(int64(1), got.Completions, "should report 1 blocking completion") - - var count int64 - suite.env.DB.Model(&programs.ProgramClassCohort{}).Where("id = ?", class.ID).Count(&count) - suite.Equal(int64(1), count, "class should still exist after blocked delete") -} - -func (suite *ClassDeleteGuardTestSuite) TestDelete_NonScheduledStatus_Blocked() { - for _, status := range []programs.ClassStatus{programs.Active, programs.Completed, programs.Cancelled, programs.Paused} { - admin, class := suite.seedEmptyClass() - suite.env.DB.Model(&programs.ProgramClassCohort{}).Where("id = ?", class.ID).Update("status", status) - - resp := NewRequest[programs.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d", class.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). - Do(). - ExpectStatus(http.StatusConflict) - - got := resp.GetData() - suite.Equal(string(status), got.NonDeletableStatus, "blocker should report the class status") - - var count int64 - suite.env.DB.Model(&programs.ProgramClassCohort{}).Where("id = ?", class.ID).Count(&count) - suite.Equal(int64(1), count, "class with status %s should still exist after blocked delete", status) - } -} - -func (suite *ClassDeleteGuardTestSuite) TestDelete_HistoryOnly_Succeeds() { - admin, class := suite.seedEmptyClass() - - suite.env.DB.Create(programs.NewChangeLogEntry( - programs.TableNameCohort, "name", - models.StringPtr("old"), models.StringPtr("new"), - class.ID, admin.ID, - )) - - NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/program-classes/%d", class.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). - Do(). - ExpectStatus(http.StatusNoContent) - - var changeLogCount int64 - suite.env.DB.Table("change_log_entries"). - Where("table_name = 'program_class_cohorts' AND parent_ref_id = ?", class.ID). - Count(&changeLogCount) - suite.Equal(int64(0), changeLogCount, "change_log_entries for the class should be cleaned up") - - var historyCount int64 - suite.env.DB.Table("program_classes_history"). - Where("table_name = 'program_class_cohorts' AND parent_ref_id = ?", class.ID). - Count(&historyCount) - suite.Equal(int64(0), historyCount, "program_classes_history rows for the class should be cleaned up") -} - -func (suite *ClassDeleteGuardTestSuite) makeStudent(facilityID uint) *models.User { - ts := fmt.Sprintf("%d", time.Now().UnixNano()) - u := &models.User{ - NameFirst: "Stu", NameLast: "Dent", - Username: "stu" + ts, Email: "stu" + ts + "@t.test", - Role: models.Student, FacilityID: facilityID, - } - suite.env.DB.Create(u) - return u -} - -func TestClassDeleteGuardTestSuite(t *testing.T) { - suite.Run(t, new(ClassDeleteGuardTestSuite)) -} diff --git a/backend/tests/integration/class_enrollments_test.go b/backend/tests/integration/class_enrollments_test.go deleted file mode 100644 index 5079f5271..000000000 --- a/backend/tests/integration/class_enrollments_test.go +++ /dev/null @@ -1,494 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestEnrollUsersInClass(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("testadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - // Create program and make it available at facility - program, err := env.CreateTestProgram("Active Class Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - t.Run("Enroll users in Active class sets enrolled_at immediately", func(t *testing.T) { - runEnrollInActiveClassTest(t, env, facility, facilityAdmin, program) - }) - - t.Run("Enroll users in Scheduled class leaves enrolled_at NULL", func(t *testing.T) { - runEnrollInScheduledClassTest(t, env, facility, facilityAdmin, program) - }) -} - -func runEnrollInActiveClassTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program) { - instructor, err := env.CreateTestInstructor(facility.ID, "enrollactive") - require.NoError(t, err) - - // Create an Active class - activeClass, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - // Create test users - user1, err := env.CreateTestUser("activeuser1", models.Student, facility.ID, "111") - require.NoError(t, err) - user2, err := env.CreateTestUser("activeuser2", models.Student, facility.ID, "222") - require.NoError(t, err) - - // Enroll users via API endpoint - enrollmentData := map[string]any{ - "user_ids": []int{int(user1.ID), int(user2.ID)}, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/enrollments", activeClass.ID), enrollmentData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - // Verify enrolled_at timestamps were set immediately for Active class - var enrollments []programs.ProgramClassEnrollment - err = env.DB.Where("cohort_id = ?", activeClass.ID).Find(&enrollments).Error - require.NoError(t, err) - require.Len(t, enrollments, 2, "Should have 2 enrollments") - - for _, enrollment := range enrollments { - require.NotNil(t, enrollment.EnrolledAt, "enrolled_at should be set immediately for Active class") - require.WithinDuration(t, time.Now(), *enrollment.EnrolledAt, time.Second*5, "enrolled_at should be recent") - require.Nil(t, enrollment.EnrollmentEndedAt, "enrollment_ended_at should be NULL initially") - require.Equal(t, programs.Enrolled, enrollment.EnrollmentStatus, "status should be Enrolled") - } -} - -func runEnrollInScheduledClassTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program) { - instructor, err := env.CreateTestInstructor(facility.ID, "enrollscheduled") - require.NoError(t, err) - - // Create a Scheduled class - scheduledClass, err := env.CreateTestClass(program, facility, programs.Scheduled, &instructor.ID) - require.NoError(t, err) - - // Create test users - user1, err := env.CreateTestUser("scheduleduser1", models.Student, facility.ID, "333") - require.NoError(t, err) - user2, err := env.CreateTestUser("scheduleduser2", models.Student, facility.ID, "444") - require.NoError(t, err) - - // Enroll users via API endpoint - enrollmentData := map[string]any{ - "user_ids": []int{int(user1.ID), int(user2.ID)}, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/enrollments", scheduledClass.ID), enrollmentData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - // Verify enrolled_at timestamps are NULL for Scheduled class - var enrollments []programs.ProgramClassEnrollment - err = env.DB.Where("cohort_id = ?", scheduledClass.ID).Find(&enrollments).Error - require.NoError(t, err) - require.Len(t, enrollments, 2, "Should have 2 enrollments") - - for _, enrollment := range enrollments { - require.Nil(t, enrollment.EnrolledAt, "enrolled_at should be NULL for Scheduled class") - require.Nil(t, enrollment.EnrollmentEndedAt, "enrollment_ended_at should be NULL initially") - require.Equal(t, programs.Enrolled, enrollment.EnrollmentStatus, "status should be Enrolled") - } -} - -func TestUpdateProgramClassEnrollments(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("testadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - t.Run("Update enrollment to terminal status on Active class sets enrollment_ended_at", func(t *testing.T) { - runUpdateToTerminalStatusTest(t, env, facility, facilityAdmin) - }) - - t.Run("Update enrollment to Cancelled status on Active class return 400 status code", func(t *testing.T) { - runUpdateToCancelledStatusTest(t, env, facility, facilityAdmin) - }) -} - -func runUpdateToTerminalStatusTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User) { - // Create program and make it available at facility - program, err := env.CreateTestProgram("Terminal Status Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "updateterminal") - require.NoError(t, err) - - // Create an Active class - activeClass, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - // Define all terminal statuses - terminalStatuses := []programs.ProgramEnrollmentStatus{ - programs.EnrollmentCompleted, - programs.EnrollmentIncompleteWithdrawn, - programs.EnrollmentIncompleteDropped, - programs.EnrollmentIncompleteFailedToComplete, - programs.EnrollmentIncompleteTransfered, - programs.EnrollmentIncompleteSegregated, - } - - // Test each terminal status - for i, status := range terminalStatuses { - t.Run(string(status), func(t *testing.T) { - // Create a unique test user for this status - user, err := env.CreateTestUser(fmt.Sprintf("terminaluser%d", i), models.Student, facility.ID, fmt.Sprintf("test%d", 100+i)) - require.NoError(t, err) - - // Create enrollment in Active class (should set enrolled_at immediately) - enrollment, err := env.CreateTestEnrollment(activeClass.ID, user.ID, programs.Enrolled) - require.NoError(t, err) - - // Verify initial state: enrolled_at set, enrollment_ended_at NULL - enrolledAt, endedAt, err := env.GetEnrollmentTimestamps(enrollment.ID) - require.NoError(t, err) - require.NotNil(t, enrolledAt, "enrolled_at should be set for Active class enrollment") - require.Nil(t, endedAt, "enrollment_ended_at should be NULL initially") - - // Update enrollment status to terminal state via API - updateData := map[string]any{ - "enrollment_status": string(status), - "user_ids": []int{int(user.ID)}, - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes/%d/enrollments", activeClass.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - // Verify enrollment_ended_at timestamp was set - var updatedEnrollment programs.ProgramClassEnrollment - err = env.DB.Where("cohort_id = ? AND user_id = ?", activeClass.ID, user.ID).First(&updatedEnrollment).Error - require.NoError(t, err) - - require.Equal(t, status, updatedEnrollment.EnrollmentStatus, "status should be updated") - require.NotNil(t, updatedEnrollment.EnrolledAt, "enrolled_at should remain set") - require.NotNil(t, updatedEnrollment.EnrollmentEndedAt, "enrollment_ended_at should be set for terminal status") - require.WithinDuration(t, time.Now(), *updatedEnrollment.EnrollmentEndedAt, time.Second*5, "enrollment_ended_at should be recent") - }) - } -} - -func runUpdateToCancelledStatusTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User) { - // Create program and make it available at facility - program, err := env.CreateTestProgram("Cancelled Status Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "terminal") - require.NoError(t, err) - - // Create an Active class - activeClass, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - cancelledStatus := programs.EnrollmentCancelled - user, err := env.CreateTestUser("statususer1", models.Student, facility.ID, "status1001") - require.NoError(t, err) - - // Create enrollment in Active class (should set enrolled_at immediately) - enrollment, err := env.CreateTestEnrollment(activeClass.ID, user.ID, programs.Enrolled) - require.NoError(t, err) - - // Verify initial state: enrolled_at set, enrollment_ended_at NULL - enrolledAt, endedAt, err := env.GetEnrollmentTimestamps(enrollment.ID) - require.NoError(t, err) - require.NotNil(t, enrolledAt, "enrolled_at should be set for Active class enrollment") - require.Nil(t, endedAt, "enrollment_ended_at should be NULL initially") - - // Update enrollment status to terminal state via API - updateData := map[string]any{ - "enrollment_status": string(cancelledStatus), - "user_ids": []int{int(user.ID)}, - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes/%d/enrollments", activeClass.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) -} -func TestGetHistoricalEnrollmentBatch(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Historical Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("histadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - // Create program and make it available at facility - program, err := env.CreateTestProgram("Historical Enrollment Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "terminal") - require.NoError(t, err) - - // Create an Active class - activeClass, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - // Create test users - user1, err := env.CreateTestUser("histuser1", models.Student, facility.ID, "hist1001") - require.NoError(t, err) - user2, err := env.CreateTestUser("histuser2", models.Student, facility.ID, "hist1002") - require.NoError(t, err) - user3, err := env.CreateTestUser("histuser3", models.Student, facility.ID, "hist1003") - require.NoError(t, err) - - // Create enrollments with specific dates - // User1: enrolled on 2025-08-13 - enrolledAt1 := time.Date(2025, 8, 13, 0, 0, 0, 0, time.UTC) - enrollment1 := &programs.ProgramClassEnrollment{ - CohortID: activeClass.ID, - UserID: user1.ID, - EnrollmentStatus: programs.Enrolled, - EnrolledAt: &enrolledAt1, - } - err = env.DB.Create(enrollment1).Error - require.NoError(t, err) - - // User2: enrolled on 2025-08-14 - enrolledAt2 := time.Date(2025, 8, 14, 0, 0, 0, 0, time.UTC) - enrollment2 := &programs.ProgramClassEnrollment{ - CohortID: activeClass.ID, - UserID: user2.ID, - EnrollmentStatus: programs.Enrolled, - EnrolledAt: &enrolledAt2, - } - err = env.DB.Create(enrollment2).Error - require.NoError(t, err) - - // User3: enrolled on 2025-08-15, ended on 2025-08-16 - enrolledAt3 := time.Date(2025, 8, 15, 0, 0, 0, 0, time.UTC) - endedAt3 := time.Date(2025, 8, 16, 0, 0, 0, 0, time.UTC) - enrollment3 := &programs.ProgramClassEnrollment{ - CohortID: activeClass.ID, - UserID: user3.ID, - EnrollmentStatus: programs.EnrollmentCompleted, - EnrolledAt: &enrolledAt3, - EnrollmentEndedAt: &endedAt3, - } - err = env.DB.Create(enrollment3).Error - require.NoError(t, err) - - t.Run("Single date with varying enrollment levels", func(t *testing.T) { - runSingleDateHistoricalEnrollmentTest(t, env, activeClass, facilityAdmin, facility) - }) - - t.Run("Batch multiple dates with varying enrollment levels", func(t *testing.T) { - runBatchDateHistoricalEnrollmentTest(t, env, activeClass, facilityAdmin, facility) - }) - - t.Run("Edge cases and validation", func(t *testing.T) { - runHistoricalEnrollmentEdgeCasesTest(t, env, activeClass, facilityAdmin, facility) - }) -} - -func runSingleDateHistoricalEnrollmentTest(t *testing.T, env *TestEnv, activeClass *programs.ProgramClassCohort, facilityAdmin *models.User, facility *models.Facility) { - // Test single date queries - testCases := []struct { - date string - expected int64 - desc string - }{ - {"2025-08-12", 0, "no enrollments before 2025-08-13"}, - {"2025-08-13", 1, "only user1 enrolled by 2025-08-13"}, - {"2025-08-14", 2, "user1 and user2 enrolled by 2025-08-14"}, - {"2025-08-15", 3, "all three users enrolled on 2025-08-15"}, - {"2025-08-16", 2, "user3 enrollment ended on 2025-08-16, so only user1 and user2"}, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - resp := NewRequest[map[string]int64](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/historical-enrollment-batch?dates=%s", activeClass.ID, tc.date), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - result := resp.GetData() - require.Contains(t, result, tc.date, "Response should contain the requested date") - require.Equal(t, tc.expected, result[tc.date], tc.desc) - }) - } -} - -func runBatchDateHistoricalEnrollmentTest(t *testing.T, env *TestEnv, activeClass *programs.ProgramClassCohort, facilityAdmin *models.User, facility *models.Facility) { - // Test batch query with multiple dates - dates := "2025-08-12,2025-08-13,2025-08-14,2025-08-15,2025-08-16" - resp := NewRequest[map[string]int64](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/historical-enrollment-batch?dates=%s", activeClass.ID, dates), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - result := resp.GetData() - - // Verify all dates are present in response - expectedResults := map[string]int64{ - "2025-08-12": 0, // no enrollments before 2025-08-13 - "2025-08-13": 1, // only user1 enrolled by 2025-08-13 - "2025-08-14": 2, // user1 and user2 enrolled by 2025-08-14 - "2025-08-15": 3, // all three users enrolled on 2025-08-15 - "2025-08-16": 2, // user3 enrollment ended on 2025-08-16, so only user1 and user2 - } - - require.Len(t, result, 5, "Should return results for all 5 dates") - - for date, expected := range expectedResults { - require.Contains(t, result, date, "Response should contain date %s", date) - require.Equal(t, expected, result[date], "Incorrect enrollment count for %s", date) - } - - // Verify this was a single API call that returned all results - t.Logf("Successfully retrieved historical enrollment data for %d dates in a single batch request", len(expectedResults)) -} - -func runHistoricalEnrollmentEdgeCasesTest(t *testing.T, env *TestEnv, activeClass *programs.ProgramClassCohort, facilityAdmin *models.User, facility *models.Facility) { - // Test case 1: Missing dates parameter - t.Run("Missing dates parameter", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/historical-enrollment-batch", activeClass.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) - }) - - // Test case 2: Empty dates parameter - t.Run("Empty dates parameter", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/historical-enrollment-batch?dates=", activeClass.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) - }) - - // Test case 3: Invalid date format - t.Run("Invalid date format", func(t *testing.T) { - invalidDates := []string{ - "2025/08/13", // wrong format - "2025-13-01", // invalid month - "2025-02-30", // invalid day - "invalid-date", // completely invalid - "2025-8-1", // missing zero padding - } - - for _, invalidDate := range invalidDates { - t.Run(fmt.Sprintf("date_%s", invalidDate), func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/historical-enrollment-batch?dates=%s", activeClass.ID, invalidDate), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) - }) - } - }) - - // Test case 4: Mixed valid and invalid dates - t.Run("Mixed valid and invalid dates", func(t *testing.T) { - dates := "2025-08-13,invalid-date,2025-08-14" - NewRequest[any](env.Client, t, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/historical-enrollment-batch?dates=%s", activeClass.ID, dates), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) - }) -} - -// TestEligibleResidentsFilteredByClassFacility verifies that GetEligibleResidentsForClass -// returns only residents at the class's own facility, regardless of the caller's facility context. -func TestEligibleResidentsFilteredByClassFacility(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facilityA, err := env.CreateTestFacility("Facility A") - require.NoError(t, err) - facilityB, err := env.CreateTestFacility("Facility B") - require.NoError(t, err) - - deptAdmin, err := env.CreateTestUser("deptadmin", models.DepartmentAdmin, facilityB.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Eligible Residents Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - err = env.SetFacilitiesToProgram(program.ID, []uint{facilityA.ID, facilityB.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facilityA.ID, "eligibletest") - require.NoError(t, err) - - // Class is at facility A - class, err := env.CreateTestClass(program, facilityA, programs.Active, &instructor.ID) - require.NoError(t, err) - - // Students at facility A - enrolledA, err := env.CreateTestUser("enra", models.Student, facilityA.ID, "ea001") - require.NoError(t, err) - unenrolledA, err := env.CreateTestUser("unenra", models.Student, facilityA.ID, "ua001") - require.NoError(t, err) - - // Student at facility B — must never appear - _, err = env.CreateTestUser("unenrb", models.Student, facilityB.ID, "ub001") - require.NoError(t, err) - - // Deactivated student at facility A — must be excluded - deactivatedA, err := env.CreateTestUser("deacta", models.Student, facilityA.ID, "da001") - require.NoError(t, err) - require.NoError(t, env.DB.Model(deactivatedA).Update("deactivated_at", "2024-01-01").Error) - - // Enroll enrolledA so they no longer appear as eligible - _, err = env.CreateTestEnrollment(class.ID, enrolledA.ID, programs.Enrolled) - require.NoError(t, err) - - t.Run("dept admin with facilityB context sees only unenrolled facilityA residents", func(t *testing.T) { - // Dept admin passes facility_id=B in the query — backend must ignore it and use class's facility - resp := NewRequest[[]models.User](env.Client, t, http.MethodGet, - fmt.Sprintf("/api/users?include=only_unenrolled&cohort_id=%d&facility_id=%d&per_page=50", class.ID, facilityB.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.DepartmentAdmin, UserID: deptAdmin.ID, FacilityID: facilityB.ID}). - Do(). - ExpectStatus(http.StatusOK) - - users := resp.GetData() - ids := make([]uint, len(users)) - for i, u := range users { - ids[i] = u.ID - } - - require.Contains(t, ids, unenrolledA.ID, "unenrolled facility-A student should be returned") - require.NotContains(t, ids, enrolledA.ID, "already-enrolled student should be excluded") - require.NotContains(t, ids, deactivatedA.ID, "deactivated student should be excluded") - for _, u := range users { - require.Equal(t, facilityA.ID, u.FacilityID, "only facility-A residents should be returned") - } - }) -} diff --git a/backend/tests/integration/class_events_multirow_test.go b/backend/tests/integration/class_events_multirow_test.go deleted file mode 100644 index bf0dc2a72..000000000 --- a/backend/tests/integration/class_events_multirow_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package integration - -import ( - "fmt" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// TestMultiRowEventSessionsAllSurface reproduces ID-692: after "Apply to all future -// sessions" a class ends up with two ProgramClassEvent rows (an UNTIL-capped original -// plus a new row). The sessions tab must show sessions from BOTH rows, not just the -// newest one. -func TestMultiRowEventSessionsAllSurface(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Multi-Row Facility") - require.NoError(t, err) - - admin, err := env.CreateTestUser("multirowadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Multi-Row Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(program.ID, []uint{facility.ID})) - - class, err := env.CreateTestClass(program, facility, programs.Active, &admin.ID) - require.NoError(t, err) - - now := time.Now().UTC() - fmtTS := func(t time.Time) string { return t.Format("20060102T150405Z") } - - // Original row: weekly, started 8 weeks ago, capped (UNTIL) 4 weeks ago. - oldStart := now.AddDate(0, 0, -56) - oldUntil := now.AddDate(0, 0, -28) - oldRule := fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", fmtTS(oldStart), fmtTS(oldUntil)) - _, err = env.CreateTestEventWithRRule(class.ID, oldRule, admin.ID) - require.NoError(t, err) - - // New row created by "Apply to all future sessions": weekly from 3 weeks ago at a - // different time, running 4 weeks into the future. - newStart := now.AddDate(0, 0, -21).Truncate(time.Hour).Add(13 * time.Hour) - newUntil := now.AddDate(0, 0, 28) - newRule := fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", fmtTS(newStart), fmtTS(newUntil)) - _, err = env.CreateTestEventWithRRule(class.ID, newRule, admin.ID) - require.NoError(t, err) - - qryCtx := &programs.QueryContext{ - Ctx: env.Context, - Timezone: "UTC", - All: true, - } - - instances, err := env.Programs.GetClassEventInstancesWithAttendanceForRecurrence(int(class.ID), qryCtx, "", "", nil, true) - require.NoError(t, err) - require.NotEmpty(t, instances, "expected sessions from both event rows") - - var earliest, latest string - for _, inst := range instances { - if earliest == "" || inst.Date < earliest { - earliest = inst.Date - } - if inst.Date > latest { - latest = inst.Date - } - } - - // Past sessions from the capped original row must still be present (the bug dropped - // these). The earliest session should be near the original 8-weeks-ago DTSTART. - pastThreshold := now.AddDate(0, 0, -50).Format("2006-01-02") - require.LessOrEqual(t, earliest, pastThreshold, - "past sessions from the original capped row should still surface (earliest=%s)", earliest) - - // Future sessions from the new row must also be present. - futureThreshold := now.AddDate(0, 0, 20).Format("2006-01-02") - require.GreaterOrEqual(t, latest, futureThreshold, - "future sessions from the new row should surface (latest=%s)", latest) -} - -// TestCancelAllFutureSessionsAcrossMultipleRows reproduces the write-side twin of -// ID-692: when a class has multiple active recurrence rows, "cancel all future -// sessions" must cap/cancel every row, not just the clicked one. Previously stray -// rows kept generating active future sessions. -func TestCancelAllFutureSessionsAcrossMultipleRows(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Cancel-Future Facility") - require.NoError(t, err) - - admin, err := env.CreateTestUser("cancelfutureadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Cancel-Future Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(program.ID, []uint{facility.ID})) - - class, err := env.CreateTestClass(program, facility, programs.Active, &admin.ID) - require.NoError(t, err) - - now := time.Now().UTC() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - dstart := func(t time.Time) string { return t.Format("20060102") + "T090000Z" } - until := func(t time.Time) string { return t.Format("20060102") + "T235959Z" } - - // Row A: the original series spanning past through future. - ruleA := fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", dstart(today.AddDate(0, 0, -28)), until(today.AddDate(0, 0, 28))) - rowA, err := env.CreateTestEventWithRRule(class.ID, ruleA, admin.ID) - require.NoError(t, err) - - // Row B: a stray future-only row left behind by a prior reschedule. The old code - // never touched this on a cancel, so its future sessions leaked through. - ruleB := fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", dstart(today.AddDate(0, 0, 7)), until(today.AddDate(0, 0, 28))) - _, err = env.CreateTestEventWithRRule(class.ID, ruleB, admin.ID) - require.NoError(t, err) - - // "Cancel all future sessions" from today: a cancelled series from today onward, - // and a closed series whose UNTIL marks the cut (today - 1). - newSeries := programs.ProgramClassEvent{ - Duration: "2h", - RecurrenceRule: fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", dstart(today), until(today.AddDate(0, 0, 28))), - RoomID: rowA.RoomID, - InstructorID: rowA.InstructorID, - IsCancelled: true, - } - closedSeries := programs.ProgramClassEvent{ - RecurrenceRule: fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", dstart(today.AddDate(0, 0, -28)), until(today.AddDate(0, 0, -1))), - } - - writeCtx := &programs.QueryContext{Ctx: env.Context, Timezone: "UTC", UserID: admin.ID} - require.NoError(t, env.Programs.CreateRescheduleEventSeries(writeCtx, class.ID, newSeries, closedSeries)) - - readCtx := &programs.QueryContext{Ctx: env.Context, Timezone: "UTC", All: true} - instances, err := env.Programs.GetClassEventInstancesWithAttendanceForRecurrence(int(class.ID), readCtx, "", "", nil, true) - require.NoError(t, err) - require.NotEmpty(t, instances) - - todayStr := today.Format("2006-01-02") - var futureCancelled, pastActive int - for _, inst := range instances { - if inst.Date >= todayStr { - require.True(t, inst.IsCancelled, - "every session on/after the cut must be cancelled, but %s is active", inst.Date) - futureCancelled++ - } else if !inst.IsCancelled { - pastActive++ - } - } - require.Positive(t, futureCancelled, "future sessions should appear as cancelled") - require.Positive(t, pastActive, "past sessions before the cut should be preserved as active") - - // The stray future-only row (B) must be soft-deleted, not left generating sessions. - var activeRows int64 - require.NoError(t, env.DB.Model(&programs.ProgramClassEvent{}). - Where("cohort_id = ? AND is_cancelled = ?", class.ID, false). - Count(&activeRows).Error) - require.Equal(t, int64(1), activeRows, "only the capped original row should remain active") -} - -// TestUncancelSeriesRestoresFutureSessions verifies that a series cancellation can be -// undone: the sessions-tab "Undo" routes to uncancel-series, which must bring the -// future sessions back as active. -func TestUncancelSeriesRestoresFutureSessions(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Uncancel Facility") - require.NoError(t, err) - - admin, err := env.CreateTestUser("uncanceladmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Uncancel Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(program.ID, []uint{facility.ID})) - - class, err := env.CreateTestClass(program, facility, programs.Active, &admin.ID) - require.NoError(t, err) - - now := time.Now().UTC() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - dstart := func(t time.Time) string { return t.Format("20060102") + "T090000Z" } - until := func(t time.Time) string { return t.Format("20060102") + "T235959Z" } - - rowA, err := env.CreateTestEventWithRRule(class.ID, - fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", dstart(today.AddDate(0, 0, -28)), until(today.AddDate(0, 0, 28))), admin.ID) - require.NoError(t, err) - - writeCtx := &programs.QueryContext{Ctx: env.Context, Timezone: "UTC", UserID: admin.ID} - require.NoError(t, env.Programs.CreateRescheduleEventSeries(writeCtx, class.ID, - programs.ProgramClassEvent{ - Duration: "2h", - RecurrenceRule: fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", dstart(today), until(today.AddDate(0, 0, 28))), - RoomID: rowA.RoomID, - InstructorID: rowA.InstructorID, - IsCancelled: true, - }, - programs.ProgramClassEvent{ - RecurrenceRule: fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=WEEKLY;UNTIL=%s", dstart(today.AddDate(0, 0, -28)), until(today.AddDate(0, 0, -1))), - })) - - // Locate the cancelled series row created by the cancel. - var cancelledSeries programs.ProgramClassEvent - require.NoError(t, env.DB.Where("cohort_id = ? AND is_cancelled = ?", class.ID, true). - Order("created_at DESC").First(&cancelledSeries).Error) - - // Undo: restore the series from today onward. - todayStr := today.Format("2006-01-02") - require.NoError(t, env.Programs.UncancelEventSeries(writeCtx, cancelledSeries.ID, todayStr)) - - readCtx := &programs.QueryContext{Ctx: env.Context, Timezone: "UTC", All: true} - instances, err := env.Programs.GetClassEventInstancesWithAttendanceForRecurrence(int(class.ID), readCtx, "", "", nil, true) - require.NoError(t, err) - - var restoredFuture int - for _, inst := range instances { - if inst.Date >= todayStr { - require.False(t, inst.IsCancelled, - "session on/after the restore date should be active again, but %s is still cancelled", inst.Date) - restoredFuture++ - } - } - require.Positive(t, restoredFuture, "future sessions should be restored as active") -} diff --git a/backend/tests/integration/class_instructor_preload_test.go b/backend/tests/integration/class_instructor_preload_test.go deleted file mode 100644 index dbd01913b..000000000 --- a/backend/tests/integration/class_instructor_preload_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package integration - -import ( - "net/http" - "net/url" - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// The Instructor column on /classes and on the program-detail Classes tab is NOT a column -// on the cohort -- the frontend derives it from the cohort's events via -// getInstructorName(cls.events), which reads event.instructor_ref. That field is only -// populated by a GORM Preload, so dropping the preload renders the column blank with no -// error anywhere: the query succeeds, the JSON is well-formed, the field is simply absent. -// Both list queries shipped without it. These tests pin it down. -func TestClassListPopulatesInstructorRef(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Preload Test Facility") - require.NoError(t, err) - - admin, err := env.CreateTestUser("preloadadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Preload Program", programs.FundingType(programs.FederalGrants), - []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(program.ID, []uint{facility.ID})) - - instructor, err := env.CreateTestInstructor(facility.ID, "preload") - require.NoError(t, err) - - cohort, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - _, err = env.CreateTestEvent(cohort.ID, "", instructor.ID) - require.NoError(t, err) - - claims := &handlers.Claims{Role: models.FacilityAdmin, UserID: admin.ID, FacilityID: facility.ID} - - t.Run("GET /api/program-classes preloads the event instructor", func(t *testing.T) { - resp := NewRequest[[]programs.ProgramClassCohort](env.Client, t, http.MethodGet, - "/api/program-classes", nil). - WithTestClaims(claims). - Do(). - ExpectStatus(http.StatusOK) - - classes := resp.GetData() - require.NotEmpty(t, classes, "expected the seeded cohort in the list") - - found := false - for _, cls := range classes { - if cls.ID != cohort.ID { - continue - } - found = true - require.NotEmpty(t, cls.Events, "cohort must carry its events, the Instructor column reads them") - ev := cls.Events[0] - require.NotNil(t, ev.Instructor, - "event.instructor_ref is nil -- Preload(\"Events.Instructor\") is missing from GetClasses, so the Instructor column on /classes renders blank") - require.Equal(t, instructor.NameFirst, ev.Instructor.NameFirst) - require.Equal(t, instructor.NameLast, ev.Instructor.NameLast) - } - require.True(t, found, "cohort %d not present in the class list", cohort.ID) - }) - - // This one calls the query directly rather than through GET /api/programs/{id}/classes. - // That handler also computes attendance rates via a raw query using Postgres `::numeric` - // casts (events_attendance_store.go), which SQLite cannot parse -- it fails with - // "unrecognized token: :" and the endpoint 500s under the test suite regardless of this - // fix. So the endpoint itself is untestable here; the preload still is. - t.Run("GetProgramClassDetailsByID preloads the event instructor", func(t *testing.T) { - details, err := env.Programs.GetProgramClassDetailsByID(int(program.ID), &programs.QueryContext{ - Ctx: env.Context, - Timezone: "UTC", - FacilityID: facility.ID, - PerPage: 10, - Page: 1, - Params: url.Values{}, - }) - require.NoError(t, err) - require.NotEmpty(t, details, "expected the seeded cohort in the program's class list") - - found := false - for _, d := range details { - if d.ID != cohort.ID { - continue - } - found = true - require.NotEmpty(t, d.Events, "cohort must carry its events") - require.NotNil(t, d.Events[0].Instructor, - "event.instructor_ref is nil -- Preload(\"Instructor\") is missing from GetProgramClassDetailsByID, so the program-detail Classes tab shows \"—\"") - require.Equal(t, instructor.NameLast, d.Events[0].Instructor.NameLast) - } - require.True(t, found, "cohort %d not present in the program's class list", cohort.ID) - }) -} diff --git a/backend/tests/integration/classes_test.go b/backend/tests/integration/classes_test.go deleted file mode 100644 index 010328189..000000000 --- a/backend/tests/integration/classes_test.go +++ /dev/null @@ -1,581 +0,0 @@ -package integration - -import ( - "fmt" - "io" - "net/http" - "os" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" -) - -func TestCreateClassHandler(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("testadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - // - [x] Create class when request is valid - // - [ ] Create class at facility not offered is invalid - // - [ ] Bad request when program id is invalid - // - [ ] Internal service error when program id isn't found - // - [x] Status conflict when program is inactive or archived - - t.Run("Create class when request is valid", func(t *testing.T) { - runCreateClassTest(t, env, facility, facilityAdmin) - }) - - t.Run("Create class for inactive program is invalid", func(t *testing.T) { - runCreateClassInactiveProgramTest(t, env, facility, facilityAdmin) - }) - - t.Run("Create class for archived program is invalid", func(t *testing.T) { - runCreateClassArchivedProgramTest(t, env, facility, facilityAdmin) - }) - - t.Run("Create class at facility not offered is invalid", func(t *testing.T) { - runCreateClassNotOfferedFacilityTest(t, env, facility, facilityAdmin) - }) -} - -// successful class is created -func runCreateClassTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User) { - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - t.Run("Create class with assigned instructor", func(t *testing.T) { - // Create instructor user (using FacilityAdmin role as instructors should be privileged) - instructor, err := env.CreateTestInstructor(facility.ID, "1") - require.NoError(t, err) - - // Create class with instructor - class := newClassWithInstructor(program, facility, &instructor.ID) - - resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - got := resp.GetData() - - require.NotZero(t, got.ID) - require.Equal(t, class.InstructorID, got.InstructorID) - require.Equal(t, class.Description, got.Description) - require.WithinDuration(t, class.StartDt, got.StartDt, time.Millisecond) - require.WithinDuration(t, *class.EndDt, *got.EndDt, time.Millisecond) - require.Equal(t, class.Status, got.Status) - require.Equal(t, class.CreditHours, got.CreditHours) - }) -} - -func runCreateClassInactiveProgramTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User) { - program, err := env.CreateTestProgram("Inactive Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, false, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "inactive") - require.NoError(t, err) - - class := newClassWithInstructor(program, facility, &instructor.ID) - - NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID}). - Do(). - ExpectStatus(http.StatusUnauthorized) -} - -func runCreateClassArchivedProgramTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User) { - archivedAt := time.Date(2022, 12, 1, 0, 0, 0, 0, time.UTC) - program, err := env.CreateTestProgram("Archived Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, &archivedAt) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "archived") - require.NoError(t, err) - - class := newClassWithInstructor(program, facility, &instructor.ID) - - NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusUnauthorized) -} - -func runCreateClassNotOfferedFacilityTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User) { - program, err := env.CreateTestProgram("Not Offered at Facility", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "notoffered") - require.NoError(t, err) - - class := newClassWithInstructor(program, facility, &instructor.ID) - - NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusUnauthorized) -} - -// A department admin (statewide) must create a class at the facility named by -// the ?facility_id= query param, not at their home facility or whatever the body -// carries. This guards the toggle-removal re-scoping. -func TestCreateClassFacilityScopingForDeptAdmin(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - homeFacility, err := env.CreateTestFacility("Home Facility") - require.NoError(t, err) - targetFacility, err := env.CreateTestFacility("Target Facility") - require.NoError(t, err) - - deptAdmin, err := env.CreateTestUser("deptadmin", models.DepartmentAdmin, homeFacility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Scoped Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - // Offered only at the target facility, not the admin's home facility. - err = env.SetFacilitiesToProgram(program.ID, []uint{targetFacility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(targetFacility.ID, "target") - require.NoError(t, err) - - t.Run("class is created at the query-param facility, not the admin's home", func(t *testing.T) { - // Body intentionally carries the home facility to prove it is ignored. - class := newClassWithInstructor(program, homeFacility, &instructor.ID) - - resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/programs/%d/classes?facility_id=%d", program.ID, targetFacility.ID), class). - WithTestClaims(&handlers.Claims{Role: models.DepartmentAdmin, UserID: deptAdmin.ID, FacilityID: homeFacility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - got := resp.GetData() - require.Equal(t, targetFacility.ID, got.FacilityID) - }) - - t.Run("missing facility_id is rejected for a statewide admin", func(t *testing.T) { - class := newClassWithInstructor(program, targetFacility, &instructor.ID) - - NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, - fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.DepartmentAdmin, UserID: deptAdmin.ID, FacilityID: homeFacility.ID}). - Do(). - ExpectStatus(http.StatusUnauthorized) - }) -} - -// creates a boilerplate class with required instructor -func newClassWithInstructor(program *programs.Program, facility *models.Facility, instructorID *uint) programs.ProgramClassCohort { - endDt := time.Now().Add(time.Hour * 24) - creditHours := int64(2) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: instructorID, - Description: "This is a test class created for integration testing purposes.", - StartDt: time.Now(), - EndDt: &endDt, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - return class -} - -func TestUpdateClasses(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("testadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - // Create program and make it available at facility - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - t.Run("Update class from Scheduled to Active sets enrolled_at for existing enrollments", func(t *testing.T) { - runUpdateClassScheduledToActiveTest(t, env, facility, facilityAdmin, program) - }) - - t.Run("Update Active class to Cancelled sets enrollment_ended_at for existing enrollments", func(t *testing.T) { - runUpdateActiveClassToCancelledTest(t, env, facility, facilityAdmin, program) - }) - - t.Run("Update Paused class to Cancelled sets enrollment_ended_at for existing enrollments", func(t *testing.T) { - runUpdatePausedClassToCancelledTest(t, env, facility, facilityAdmin, program) - }) - - t.Run("Update Active to Paused to Active preserves original enrolled_at timestamps", func(t *testing.T) { - runUpdateActivePausedActiveTest(t, env, facility, facilityAdmin, program) - }) - t.Run("Update class from Active to Completed", func(t *testing.T) { - runUpdateActiveClassToCompletedTest(t, env, facility, facilityAdmin) - }) -} - -func runUpdateClassScheduledToActiveTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program) { - instructor, err := env.CreateTestInstructor(facility.ID, "scheduled") - require.NoError(t, err) - - // Create a scheduled class using helper function - class, err := env.CreateTestClass(program, facility, programs.Scheduled, &instructor.ID) - require.NoError(t, err) - - // Create test users - user1, err := env.CreateTestUser("testuser1", models.Student, facility.ID, "123") - require.NoError(t, err) - user2, err := env.CreateTestUser("testuser2", models.Student, facility.ID, "456") - require.NoError(t, err) - - // Create enrollments using helper function - enrollment1, err := env.CreateTestEnrollment(class.ID, user1.ID, programs.Enrolled) - require.NoError(t, err) - enrollment2, err := env.CreateTestEnrollment(class.ID, user2.ID, programs.Enrolled) - require.NoError(t, err) - - // Verify enrollments start with enrolled_at = NULL - enrolledAt1, endedAt1, err := env.GetEnrollmentTimestamps(enrollment1.ID) - require.NoError(t, err) - require.Nil(t, enrolledAt1, "enrolled_at should be NULL for scheduled class") - require.Nil(t, endedAt1, "enrollment_ended_at should be NULL initially") - - enrolledAt2, endedAt2, err := env.GetEnrollmentTimestamps(enrollment2.ID) - require.NoError(t, err) - require.Nil(t, enrolledAt2, "enrolled_at should be NULL for scheduled class") - require.Nil(t, endedAt2, "enrollment_ended_at should be NULL initially") - - // Update class status to Active via API using the bulk update endpoint - updateData := map[string]any{ - "status": string(programs.Active), - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - // Verify enrolled_at timestamps were set - enrolledAt1After, endedAt1After, err := env.GetEnrollmentTimestamps(enrollment1.ID) - require.NoError(t, err) - require.NotNil(t, enrolledAt1After, "enrolled_at should be set when class becomes Active") - require.WithinDuration(t, time.Now(), *enrolledAt1After, time.Second*5, "enrolled_at should be recent") - require.Nil(t, endedAt1After, "enrollment_ended_at should still be NULL") - - enrolledAt2After, endedAt2After, err := env.GetEnrollmentTimestamps(enrollment2.ID) - require.NoError(t, err) - require.NotNil(t, enrolledAt2After, "enrolled_at should be set when class becomes Active") - require.WithinDuration(t, time.Now(), *enrolledAt2After, time.Second*5, "enrolled_at should be recent") - require.Nil(t, endedAt2After, "enrollment_ended_at should still be NULL") -} - -func runUpdateActiveClassToCancelledTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program) { - instructor, err := env.CreateTestInstructor(facility.ID, "active") - require.NoError(t, err) - - // Create an Active class - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - _, err = env.CreateTestEvent(class.ID, "", instructor.ID) - require.NoError(t, err) - - // Create test users - user1, err := env.CreateTestUser("activeuser1", models.Student, facility.ID, "789") - require.NoError(t, err) - user2, err := env.CreateTestUser("activeuser2", models.Student, facility.ID, "790") - require.NoError(t, err) - - // Create enrollments in Active class (should set enrolled_at immediately) - enrollment1, err := env.CreateTestEnrollment(class.ID, user1.ID, programs.Enrolled) - require.NoError(t, err) - enrollment2, err := env.CreateTestEnrollment(class.ID, user2.ID, programs.Enrolled) - require.NoError(t, err) - - // Verify initial state: enrolled_at set, enrollment_ended_at NULL - enrolledAt1, endedAt1, err := env.GetEnrollmentTimestamps(enrollment1.ID) - require.NoError(t, err) - require.NotNil(t, enrolledAt1, "enrolled_at should be set for Active class") - require.Nil(t, endedAt1, "enrollment_ended_at should be NULL initially") - - enrolledAt2, endedAt2, err := env.GetEnrollmentTimestamps(enrollment2.ID) - require.NoError(t, err) - require.NotNil(t, enrolledAt2, "enrolled_at should be set for Active class") - require.Nil(t, endedAt2, "enrollment_ended_at should be NULL initially") - - // Update class status to Cancelled via API - updateData := map[string]any{ - "status": string(programs.Cancelled), - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - // Verify enrollment statuses were updated and enrollment_ended_at was set - var updatedEnrollments []programs.ProgramClassEnrollment - err = env.DB.Where("cohort_id = ?", class.ID).Find(&updatedEnrollments).Error - require.NoError(t, err) - require.Len(t, updatedEnrollments, 2) - - for _, enrollment := range updatedEnrollments { - require.Equal(t, programs.EnrollmentCancelled, enrollment.EnrollmentStatus, "enrollment should be cancelled when class is cancelled") - require.NotNil(t, enrollment.EnrolledAt, "enrolled_at should remain set") - require.NotNil(t, enrollment.EnrollmentEndedAt, "enrollment_ended_at should be set when class is cancelled") - require.WithinDuration(t, time.Now(), *enrollment.EnrollmentEndedAt, time.Second*5, "enrollment_ended_at should be recent") - } -} - -func runUpdatePausedClassToCancelledTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program) { - instructor, err := env.CreateTestInstructor(facility.ID, "paused") - require.NoError(t, err) - - // Create an Active class first - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - _, err = env.CreateTestEvent(class.ID, "", instructor.ID) - require.NoError(t, err) - - // Create test users - user1, err := env.CreateTestUser("pauseduser1", models.Student, facility.ID, "791") - require.NoError(t, err) - user2, err := env.CreateTestUser("pauseduser2", models.Student, facility.ID, "792") - require.NoError(t, err) - - // Create enrollments in Active class (should set enrolled_at immediately) - enrollment1, err := env.CreateTestEnrollment(class.ID, user1.ID, programs.Enrolled) - require.NoError(t, err) - enrollment2, err := env.CreateTestEnrollment(class.ID, user2.ID, programs.Enrolled) - require.NoError(t, err) - - // Verify initial state: enrolled_at set, enrollment_ended_at NULL - enrolledAt1, endedAt1, err := env.GetEnrollmentTimestamps(enrollment1.ID) - require.NoError(t, err) - require.NotNil(t, enrolledAt1, "enrolled_at should be set for Active class") - require.Nil(t, endedAt1, "enrollment_ended_at should be NULL initially") - - enrolledAt2, endedAt2, err := env.GetEnrollmentTimestamps(enrollment2.ID) - require.NoError(t, err) - require.NotNil(t, enrolledAt2, "enrolled_at should be set for Active class") - require.Nil(t, endedAt2, "enrollment_ended_at should be NULL initially") - - // Update class status from Active to Paused via API - updateData := map[string]any{ - "status": string(programs.Paused), - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - // Update class status from Paused to Cancelled via API - updateData = map[string]any{ - "status": string(programs.Cancelled), - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - // Verify enrollment statuses were updated and enrollment_ended_at was set - var updatedEnrollments []programs.ProgramClassEnrollment - err = env.DB.Where("cohort_id = ?", class.ID).Find(&updatedEnrollments).Error - require.NoError(t, err) - require.Len(t, updatedEnrollments, 2) - - for _, enrollment := range updatedEnrollments { - require.Equal(t, programs.EnrollmentCancelled, enrollment.EnrollmentStatus, "enrollment should be cancelled when class is cancelled") - require.NotNil(t, enrollment.EnrolledAt, "enrolled_at should remain set since class was previously Active") - require.NotNil(t, enrollment.EnrollmentEndedAt, "enrollment_ended_at should be set when class is cancelled") - require.WithinDuration(t, time.Now(), *enrollment.EnrollmentEndedAt, time.Second*5, "enrollment_ended_at should be recent") - } -} - -func runUpdateActivePausedActiveTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program) { - instructor, err := env.CreateTestInstructor(facility.ID, "activepaused") - require.NoError(t, err) - - // Create an Active class - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - // Create test user and enrollment - user, err := env.CreateTestUser("activepauseduser", models.Student, facility.ID, "793") - require.NoError(t, err) - - enrollment, err := env.CreateTestEnrollment(class.ID, user.ID, programs.Enrolled) - require.NoError(t, err) - - // Capture original enrolled_at timestamp - originalEnrolledAt, endedAt, err := env.GetEnrollmentTimestamps(enrollment.ID) - require.NoError(t, err) - require.NotNil(t, originalEnrolledAt, "enrolled_at should be set for Active class") - require.Nil(t, endedAt, "enrollment_ended_at should be NULL initially") - - // Wait to ensure any new timestamps would be different - time.Sleep(time.Millisecond * 50) - - // Update class status from Active to Paused - updateData := map[string]interface{}{ - "status": string(programs.Paused), - } - - NewRequest[interface{}](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - // Verify enrolled_at timestamp remains unchanged after pausing - enrolledAtAfterPause, endedAtAfterPause, err := env.GetEnrollmentTimestamps(enrollment.ID) - require.NoError(t, err) - require.Equal(t, originalEnrolledAt, enrolledAtAfterPause, "enrolled_at should not change when pausing") - require.Nil(t, endedAtAfterPause, "enrollment_ended_at should still be NULL after pausing") - - // Update class status from Paused back to Active - updateData = map[string]interface{}{ - "status": string(programs.Active), - } - - NewRequest[interface{}](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - // Verify enrolled_at timestamp STILL remains unchanged (not updated to new timestamp) - enrolledAtFinal, endedAtFinal, err := env.GetEnrollmentTimestamps(enrollment.ID) - require.NoError(t, err) - require.Equal(t, originalEnrolledAt, enrolledAtFinal, "enrolled_at should preserve original timestamp when going Paused->Active") - require.Nil(t, endedAtFinal, "enrollment_ended_at should still be NULL after reactivating") -} - -// When a class is marked Completed: -// - All enrollments become Completed -// - A ClassCompletion record is created for each affected student - -func runUpdateActiveClassToCompletedTest(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User) { - // Create program and make it available at facility - program, err := env.CreateTestProgram("Active Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "completed") - require.NoError(t, err) - - // Create an Active class - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - _, err = env.CreateTestEvent(class.ID, "", instructor.ID) - require.NoError(t, err) - - // Create test users - user1, err := env.CreateTestUser("student1", models.Student, facility.ID, "794") - require.NoError(t, err) - user2, err := env.CreateTestUser("student2", models.Student, facility.ID, "795") - require.NoError(t, err) - // Create enrollments - _, err = env.CreateTestEnrollment(class.ID, user1.ID, programs.Enrolled) - require.NoError(t, err) - _, err = env.CreateTestEnrollment(class.ID, user2.ID, programs.Enrolled) - require.NoError(t, err) - - var preUpdateEnrollments []programs.ProgramClassEnrollment - err = env.DB.Where("cohort_id = ? AND user_id in (?, ?)", class.ID, user1.ID, user2.ID).Find(&preUpdateEnrollments).Error - require.NoError(t, err) - - for _, enrollment := range preUpdateEnrollments { - require.Equal(t, programs.Enrolled, enrollment.EnrollmentStatus, "enrollment status should be Enrolled") - } - - var preUpdateEnrollmentsCount int64 - err = env.DB.Model(&programs.ProgramClassEnrollment{}). - Where("cohort_id = ? AND enrollment_status = ?", class.ID, programs.Enrolled). - Count(&preUpdateEnrollmentsCount).Error - require.NoError(t, err) - - updateData := map[string]any{ - "status": string(programs.Completed), - } - - // Call to /api/program-classes API endpoint with updateData as the request payload - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - // Exactly 2 enrollments are found for the users in the class with an EnrollmentStatus of EnrollmentCompleted - var afterUpdateEnrollments []programs.ProgramClassEnrollment - err = env.DB.Where("cohort_id = ? AND user_id in (?, ?)", class.ID, user1.ID, user2.ID).Find(&afterUpdateEnrollments).Error - require.NoError(t, err) - require.Len(t, afterUpdateEnrollments, 2) - - for _, enrollment := range afterUpdateEnrollments { - require.Equal(t, programs.EnrollmentCompleted, enrollment.EnrollmentStatus, "enrollment status should be Completed") - } - - // all enrollments with the same class.ID should be completed - var afterUpdateEnrollmentsCount int64 - err = env.DB.Model(&programs.ProgramClassEnrollment{}). - Where("cohort_id = ? AND enrollment_status = ?", class.ID, programs.Enrolled). - Count(&afterUpdateEnrollmentsCount).Error - require.NoError(t, err) - require.Equal(t, 0, int(afterUpdateEnrollmentsCount), "records with enrollment_status of Enrolled should update to Completed so the count should be 0") - - // query the program_completion table for records with a program_class_id matching the ID of the updated class. - var programCompletions []programs.ClassCompletion - err = env.DB. - Where("user_id IN (?, ?)", user1.ID, user2.ID). - Where("cohort_id = ?", class.ID). - Find(&programCompletions).Error - require.NoError(t, err) - - // Check if the same number of program completion records were created as the students enrolled (this is the expected behavior) - require.Len( - t, - programCompletions, - int(preUpdateEnrollmentsCount), - "number of program completions (%d) should be equal to the previous number of enrollments (%d)", - len(programCompletions), - preUpdateEnrollmentsCount, - ) -} - -func TestMain(m *testing.M) { - logrus.SetOutput(io.Discard) - // Run in UTC, as CI does, so results that depend on the server's local - // time zone are the same on every machine. - time.Local = time.UTC - code := m.Run() - os.Exit(code) -} diff --git a/backend/tests/integration/conflict_detection_test.go b/backend/tests/integration/conflict_detection_test.go deleted file mode 100644 index 60e6a6689..000000000 --- a/backend/tests/integration/conflict_detection_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestSchedulingConflictDetection(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Conflict Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("conflictadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Conflict Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructorA, err := env.CreateTestInstructor(facility.ID, "conflicta") - require.NoError(t, err) - - instructorB, err := env.CreateTestInstructor(facility.ID, "conflictb") - require.NoError(t, err) - - // Create Class A: Mon/Wed/Fri 10:00-11:00 - classA, err := env.CreateTestClass(program, facility, programs.Active, &instructorA.ID) - require.NoError(t, err) - classB, err := env.CreateTestClass(program, facility, programs.Active, &instructorB.ID) - require.NoError(t, err) - - // Create test room for events - room := programs.Room{FacilityID: facility.ID, Name: "Conflict Test Room"} - err = env.DB.Create(&room).Error - require.NoError(t, err) - - // Update Class A event to be specific - eventA := programs.ProgramClassEvent{ - CohortID: classA.ID, - Duration: "1h", - RecurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR;DTSTART=20240101T100000Z", // UTC - RoomID: &room.ID, - InstructorID: &instructorA.ID, - } - err = env.DB.Create(&eventA).Error - require.NoError(t, err) - - // Update Class B event to overlap - eventB := programs.ProgramClassEvent{ - CohortID: classB.ID, - Duration: "1h", - RecurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR;DTSTART=20240101T103000Z", // UTC, overlaps by 30 mins - RoomID: &room.ID, - InstructorID: &instructorB.ID, - } - err = env.DB.Create(&eventB).Error - require.NoError(t, err) - - user, err := env.CreateTestUser("conflictuser", models.Student, facility.ID, "999") - require.NoError(t, err) - - // Enroll in Class A - enrollmentDataA := map[string]interface{}{ - "user_ids": []int{int(user.ID)}, - } - NewRequest[interface{}](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/enrollments", classA.ID), enrollmentDataA). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - // Enroll in Class B (Should fail with 409 Conflict) - enrollmentDataB := map[string]interface{}{ - "user_ids": []int{int(user.ID)}, - } - NewRequest[interface{}](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/enrollments", classB.ID), enrollmentDataB). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusConflict) - - // Enroll in Class B with confirmation (Should succeed) - enrollmentDataBConfirm := map[string]interface{}{ - "user_ids": []int{int(user.ID)}, - "confirm": true, - } - NewRequest[interface{}](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/enrollments", classB.ID), enrollmentDataBConfirm). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) -} diff --git a/backend/tests/integration/enrollment_class_id_test.go b/backend/tests/integration/enrollment_class_id_test.go deleted file mode 100644 index 50701d535..000000000 --- a/backend/tests/integration/enrollment_class_id_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package integration - -import ( - "context" - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// program_class_enrollments carries BOTH cohort_id and class_id, and the class tier's -// "enrolled" rollup keys on class_id (classRollupsFor: WHERE e.class_id = pc.id). -// -// CreateProgramClassEnrollments sets only CohortID, relying on ProgramClassEnrollment's -// BeforeCreate hook to denormalize the parent. If that hook ever stops firing on the -// BATCH insert path -- db.Create(&slice) rather than a single struct -- class_id lands as -// 0, the INSERT still succeeds, and every class rollup silently reports 0 enrolled. No -// error, no failing type check: exactly the fail-green shape this ticket keeps producing. -// -// So this asserts the denormalization on the batch path specifically, and then asserts -// the number the UI actually reads. -func TestBatchEnrollmentDenormalizesClassID(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Rollup Facility") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Rollup Program", programs.FundingType(programs.FederalGrants), - []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(program.ID, []uint{facility.ID})) - - instructor, err := env.CreateTestInstructor(facility.ID, "rollup") - require.NoError(t, err) - - cohort, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - require.NotZero(t, cohort.ClassID, "the auto-parent hook should have created a class") - - residentA, err := env.CreateTestUser("rollupresa", models.Student, facility.ID, "RA1") - require.NoError(t, err) - residentB, err := env.CreateTestUser("rollupresb", models.Student, facility.ID, "RB1") - require.NoError(t, err) - - // the handler path: one call, many users, a single batch INSERT - skipped, err := env.Programs.CreateProgramClassEnrollments(int(cohort.ID), []int{int(residentA.ID), int(residentB.ID)}) - require.NoError(t, err) - require.Zero(t, skipped, "capacity is 10, neither resident should be skipped") - - t.Run("class_id is denormalized on every batch-inserted row", func(t *testing.T) { - var enrollments []programs.ProgramClassEnrollment - require.NoError(t, env.DB.Where("cohort_id = ?", cohort.ID).Find(&enrollments).Error) - require.Len(t, enrollments, 2) - - for _, e := range enrollments { - require.NotZero(t, e.ClassID, - "class_id was left at 0 -- the BeforeCreate hook did not fire on the batch insert, "+ - "so every class rollup will report 0 enrolled") - require.Equal(t, cohort.ClassID, e.ClassID, - "class_id must be the cohort's PARENT class, not the cohort id") - } - }) - - t.Run("the class rollup the UI reads counts them", func(t *testing.T) { - args := &programs.QueryContext{Ctx: context.Background()} - class, err := env.Programs.GetClassByID(int(cohort.ClassID), args) - require.NoError(t, err) - require.Equal(t, int64(2), class.Enrolled, - "classRollupsFor keys on e.class_id; a 0 here means the denormalization was lost") - }) -} diff --git a/backend/tests/integration/event_instructor_change_test.go b/backend/tests/integration/event_instructor_change_test.go deleted file mode 100644 index 0e6d88a5e..000000000 --- a/backend/tests/integration/event_instructor_change_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// Changing ONLY the instructor on a session must not be reported as a room conflict. -// -// The schedule's single-session editor always sends the CURRENT room_id alongside the -// field being edited, and handleEventOverrides used to run a room-only conflict check -// (CheckRRuleConflicts ignores InstructorID) whenever room_id was present. So an -// instructor-only edit re-validated a room that was not changing and came back -// "room is already booked during this time". -func TestChangeInstructorOnlyIsNotARoomConflict(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Conflict Facility") - require.NoError(t, err) - - admin, err := env.CreateTestUser("conflictadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Conflict Program", programs.FundingType(programs.FederalGrants), - []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(program.ID, []uint{facility.ID})) - - instructorA, err := env.CreateTestInstructor(facility.ID, "conflicta") - require.NoError(t, err) - instructorB, err := env.CreateTestInstructor(facility.ID, "conflictb") - require.NoError(t, err) - - cohort, err := env.CreateTestClass(program, facility, programs.Active, &instructorA.ID) - require.NoError(t, err) - - event, err := env.CreateTestEvent(cohort.ID, "", instructorA.ID) - require.NoError(t, err) - - room := programs.Room{FacilityID: facility.ID, Name: "Conflict Room"} - require.NoError(t, env.DB.Create(&room).Error) - require.NoError(t, env.DB.Model(&programs.ProgramClassEvent{}). - Where("id = ?", event.ID). - Update("room_id", room.ID).Error) - - // A DIFFERENT class already books the same room at the same time. This is the state - // that made the old code 409: the event's own bookings are excluded by event/cohort id, - // so without a competing booking there is nothing to conflict with and the test would - // pass either way. The clash is pre-existing and the instructor edit does not touch it. - otherCohort, err := env.CreateTestClass(program, facility, programs.Active, &instructorA.ID) - require.NoError(t, err) - otherEvent, err := env.CreateTestEvent(otherCohort.ID, "DTSTART;TZID=UTC:20260901T090000\nRRULE:FREQ=DAILY", instructorA.ID) - require.NoError(t, err) - require.NoError(t, env.DB.Model(&programs.ProgramClassEvent{}). - Where("id = ?", otherEvent.ID). - Update("room_id", room.ID).Error) - - claims := &handlers.Claims{Role: models.FacilityAdmin, UserID: admin.ID, FacilityID: facility.ID} - - // Exactly what the schedule sends for "change instructor on this session": the new - // instructor, and the room the session already had. - body := []map[string]any{{ - "event_id": event.ID, - "cohort_id": cohort.ID, - "override_rrule": "DTSTART;TZID=UTC:20260901T090000\nRRULE:FREQ=DAILY;COUNT=1", - "duration": "1h0m0s", - "room_id": room.ID, - "instructor_id": instructorB.ID, - "is_cancelled": false, - }} - - NewRequest[any](env.Client, t, http.MethodPut, - fmt.Sprintf("/api/program-classes/%d/events/%d", cohort.ID, event.ID), body). - WithTestClaims(claims). - Do(). - ExpectStatus(http.StatusOK) -} diff --git a/backend/tests/integration/facility_delete_guard_test.go b/backend/tests/integration/facility_delete_guard_test.go index 504396c5e..f61f4139d 100644 --- a/backend/tests/integration/facility_delete_guard_test.go +++ b/backend/tests/integration/facility_delete_guard_test.go @@ -8,8 +8,8 @@ import ( "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" "github.com/stretchr/testify/suite" ) @@ -27,11 +27,7 @@ func (suite *FacilityDeleteGuardTestSuite) TearDownSuite() { } func (suite *FacilityDeleteGuardTestSuite) SetupTest() { - suite.env.DB.Exec("DELETE FROM program_class_enrollments") - suite.env.DB.Exec("DELETE FROM program_classes") - suite.env.DB.Exec("DELETE FROM facilities_programs") - suite.env.DB.Exec("DELETE FROM programs") - suite.env.DB.Exec("DELETE FROM rooms") + suite.env.Programs.set(func(f *fakePrograms) { f.facilityPrograms = nil }) suite.env.DB.Exec("DELETE FROM users WHERE role != 'system_admin'") suite.env.DB.Exec("DELETE FROM facilities") } @@ -106,7 +102,9 @@ func (suite *FacilityDeleteGuardTestSuite) TestDelete_BlockedByRoom() { admin := suite.seedAdmin() facility := suite.seedFacility() - suite.env.DB.Create(&programs.Room{FacilityID: facility.ID, Name: "101"}) + suite.env.Programs.set(func(f *fakePrograms) { + f.facilityPrograms = map[uint]programsapi.FacilityPrograms{facility.ID: {Rooms: 1}} + }) resp := NewRequest[models.FacilityBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, fmt.Sprintf("/api/facilities/%d", facility.ID), nil). @@ -124,15 +122,8 @@ func (suite *FacilityDeleteGuardTestSuite) TestDelete_BlockedByClass() { admin := suite.seedAdmin() facility := suite.seedFacility() - ts := fmt.Sprintf("%d", time.Now().UnixNano()) - program := &programs.Program{Name: "Prog " + ts, IsActive: true} - suite.env.DB.Create(program) - suite.env.DB.Create(&programs.FacilitiesPrograms{FacilityID: facility.ID, ProgramID: program.ID}) - // Status and Capacity are COHORT-level now -- the class tier has neither, and the - // facility guard counts program_classes (the class tier), so a bare class is enough. - suite.env.DB.Create(&programs.ProgramClass{ - ProgramID: program.ID, FacilityID: facility.ID, - Name: "Class " + ts, Description: models.StringPtr("x"), + suite.env.Programs.set(func(f *fakePrograms) { + f.facilityPrograms = map[uint]programsapi.FacilityPrograms{facility.ID: {Classes: 1, Programs: 1}} }) resp := NewRequest[models.FacilityBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, diff --git a/backend/tests/integration/facility_feature_flags_test.go b/backend/tests/integration/facility_feature_flags_test.go index 6c35df85c..4364cddd6 100644 --- a/backend/tests/integration/facility_feature_flags_test.go +++ b/backend/tests/integration/facility_feature_flags_test.go @@ -486,8 +486,8 @@ func TestFeatureFlags_MiddlewareEnforcesClaims(t *testing.T) { facility, err := env.CreateTestFacility("Enforcement Facility") require.NoError(t, err) - t.Run("student without program access is rejected from a program-gated route", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodGet, "/api/programs", nil). + t.Run("student without the learning record is rejected from a route gated on it", func(t *testing.T) { + NewRequest[any](env.Client, t, http.MethodGet, "/api/learning-record/entries", nil). WithTestClaims(&handlers.Claims{ Role: models.Student, FacilityID: facility.ID, @@ -497,12 +497,12 @@ func TestFeatureFlags_MiddlewareEnforcesClaims(t *testing.T) { ExpectStatus(http.StatusUnauthorized) }) - t.Run("student with program access succeeds on the same route", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodGet, "/api/programs", nil). + t.Run("student with the learning record succeeds on the same route", func(t *testing.T) { + NewRequest[any](env.Client, t, http.MethodGet, "/api/learning-record/entries", nil). WithTestClaims(&handlers.Claims{ Role: models.Student, FacilityID: facility.ID, - FeatureAccess: []models.FeatureAccess{models.ProgramAccess}, + FeatureAccess: []models.FeatureAccess{models.LearningRecordAccess}, }). Do(). ExpectStatus(http.StatusOK) diff --git a/backend/tests/integration/instructor_conflict_test.go b/backend/tests/integration/instructor_conflict_test.go deleted file mode 100644 index d299062fa..000000000 --- a/backend/tests/integration/instructor_conflict_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// TestInstructorConflictDetection covers ID-642: assigning an instructor who is -// already teaching another class at an overlapping time must be detected. -func TestInstructorConflictDetection(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Instructor Conflict Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("instconflictadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Instructor Conflict Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - room := &programs.Room{FacilityID: facility.ID, Name: "Instructor Conflict Room"} - require.NoError(t, env.DB.Create(room).Error) - - t.Run("Detects overlapping instructor bookings", func(t *testing.T) { - testInstructorDetectsOverlap(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("No conflict for different times", func(t *testing.T) { - testInstructorNoConflictDifferentTimes(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Handler returns 409 when changing class instructor to a double-booked one", func(t *testing.T) { - testHandlerReturns409OnInstructorChange(t, env, facility, facilityAdmin, program, room.ID) - }) -} - -// helper: create a class with an instructor and a single daily event in the given window. -func createClassWithEvent(t *testing.T, env *TestEnv, facility *models.Facility, admin *models.User, program *programs.Program, name string, instructorID, roomID uint, recurrenceRule, duration string, start time.Time, end time.Time) *programs.ProgramClassCohort { - creditHours := int64(2) - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructorID, - Description: name, - StartDt: start, - EndDt: &end, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: admin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - created := resp.GetData() - // `name` now belongs to the CLASS tier -- that is what conflicts display. - require.NoError(t, env.NameCohortClass(created.ID, name)) - - eventPayload := map[string]interface{}{ - "duration": duration, - "room_id": roomID, - "recurrence_rule": recurrenceRule, - "instructor_id": instructorID, - } - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", created.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: admin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - return created -} - -func testInstructorDetectsOverlap(t *testing.T, env *TestEnv, facility *models.Facility, admin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "instoverlap") - require.NoError(t, err) - - start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) - createClassWithEvent(t, env, facility, admin, program, "Instructor Morning Class", instructor.ID, roomID, - "DTSTART:20260302T090000Z\nRRULE:FREQ=DAILY;UNTIL=20260331T000000Z", "2h", start, end) - - // Same instructor, overlapping window (9:30) -> conflict. - conflicts, err := env.Programs.CheckConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - InstructorID: instructor.ID, - RecurrenceRule: "DTSTART:20260302T093000Z\nRRULE:FREQ=DAILY;UNTIL=20260310T000000Z", - Duration: "1h", - }) - require.NoError(t, err) - require.NotEmpty(t, conflicts, "should detect instructor double-booking for overlapping times") - require.Equal(t, programs.ConflictTypeInstructor, conflicts[0].ConflictType, "conflict should be tagged as instructor") - require.Equal(t, "Instructor Morning Class", conflicts[0].ClassName) -} - -func testInstructorNoConflictDifferentTimes(t *testing.T, env *TestEnv, facility *models.Facility, admin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "instnoconf") - require.NoError(t, err) - - start := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC) - createClassWithEvent(t, env, facility, admin, program, "Instructor AM Class", instructor.ID, roomID, - "DTSTART:20260504T090000Z\nRRULE:FREQ=DAILY;UNTIL=20260531T000000Z", "2h", start, end) - - // Same instructor but a non-overlapping afternoon window (2-4 PM) -> no conflict. - conflicts, err := env.Programs.CheckConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - InstructorID: instructor.ID, - RecurrenceRule: "DTSTART:20260504T140000Z\nRRULE:FREQ=DAILY;UNTIL=20260510T000000Z", - Duration: "2h", - }) - require.NoError(t, err) - require.Empty(t, conflicts, "should NOT detect instructor conflict for non-overlapping times") -} - -// testHandlerReturns409OnInstructorChange reproduces the ticket scenario: change -// class X's instructor to the instructor of overlapping class Y -> 409. -func testHandlerReturns409OnInstructorChange(t *testing.T, env *TestEnv, facility *models.Facility, admin *models.User, program *programs.Program, roomID uint) { - instructor1, err := env.CreateTestInstructor(facility.ID, "instchangea") - require.NoError(t, err) - instructor2, err := env.CreateTestInstructor(facility.ID, "instchangeb") - require.NoError(t, err) - - start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - end := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) - - // Class 1 taught by instructor1 at 10:00. - createClassWithEvent(t, env, facility, admin, program, "Class Y (instructor1)", instructor1.ID, roomID, - "DTSTART:20260601T100000Z\nRRULE:FREQ=DAILY;UNTIL=20260630T000000Z", "2h", start, end) - - // Class 2 taught by instructor2 at an overlapping 10:30 (different room avoids a room conflict). - room2 := &programs.Room{FacilityID: facility.ID, Name: "Instructor Conflict Room 2"} - require.NoError(t, env.DB.Create(room2).Error) - class2 := createClassWithEvent(t, env, facility, admin, program, "Class X (instructor2)", instructor2.ID, room2.ID, - "DTSTART:20260601T103000Z\nRRULE:FREQ=DAILY;UNTIL=20260630T000000Z", "1h", start, end) - - // Changing class X's instructor to instructor1 collides with class Y -> 409. - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/programs/%d/classes/%d", program.ID, class2.ID), map[string]interface{}{ - "instructor_id": instructor1.ID, - }). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: admin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusConflict) - - // Changing to a free instructor succeeds. - instructor3, err := env.CreateTestInstructor(facility.ID, "instchangec") - require.NoError(t, err) - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/programs/%d/classes/%d", program.ID, class2.ID), map[string]interface{}{ - "instructor_id": instructor3.ID, - }). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: admin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) -} diff --git a/backend/tests/integration/program_completion_flag_test.go b/backend/tests/integration/program_completion_flag_test.go deleted file mode 100644 index ae19dd367..000000000 --- a/backend/tests/integration/program_completion_flag_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestProgramCompletionFlag(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Completion Flag Facility") - require.NoError(t, err) - - admin, err := env.CreateTestUser("flagadmin", models.SystemAdmin, facility.ID, "") - require.NoError(t, err) - - claims := &handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: facility.ID} - - body := map[string]any{ - "name": "Flag Program", - "description": "program with its own completion", - "funding_type": programs.FederalGrants, - "is_active": true, - "has_program_completion": true, - "program_types": []map[string]any{{"program_type": "Educational"}}, - "credit_types": []map[string]any{{"credit_type": "Completion"}}, - "facilities": []uint{facility.ID}, - } - - created := NewRequest[programs.Program](env.Client, t, http.MethodPost, "/api/programs", body). - WithTestClaims(claims). - Do(). - ExpectStatus(http.StatusCreated). - GetData() - - require.True(t, created.HasProgramCompletion, "flag must survive create") - - var stored programs.Program - require.NoError(t, env.DB.First(&stored, created.ID).Error) - require.True(t, stored.HasProgramCompletion, "flag must be persisted, not just echoed back") - - t.Run("defaults to false when the field is omitted", func(t *testing.T) { - plain := map[string]any{ - "name": "Plain Program", - "description": "no program-level completion", - "funding_type": programs.FederalGrants, - "is_active": true, - "program_types": []map[string]any{{"program_type": "Educational"}}, - "credit_types": []map[string]any{{"credit_type": "Completion"}}, - "facilities": []uint{facility.ID}, - } - p := NewRequest[programs.Program](env.Client, t, http.MethodPost, "/api/programs", plain). - WithTestClaims(claims). - Do(). - ExpectStatus(http.StatusCreated). - GetData() - - require.False(t, p.HasProgramCompletion, - "omitting the field must mean this is pre has completion behaviour, never true") - }) - - t.Run("can be turned off again through update", func(t *testing.T) { - update := map[string]any{ - "name": "Flag Program", - "description": "program with its own completion", - "funding_type": programs.FederalGrants, - "is_active": true, - "has_program_completion": false, - "program_types": []map[string]any{{"program_type": "Educational"}}, - "credit_types": []map[string]any{{"credit_type": "Completion"}}, - "facilities": []uint{facility.ID}, - } - NewRequest[programs.Program](env.Client, t, http.MethodPatch, - fmt.Sprintf("/api/programs/%d", created.ID), update). - WithTestClaims(claims). - Do(). - ExpectStatus(http.StatusOK) - - var after programs.Program - require.NoError(t, env.DB.First(&after, created.ID).Error) - require.False(t, after.HasProgramCompletion, - "clearing the flag must persist -- if this fails, HasProgramCompletion is missing "+ - "from the Select list in UpdateProgram and every change to it is silently dropped") - }) - - // The program-detail Edit Program dialog does not send this field at all. Because the - // column IS in UpdateProgram's Select list, a plain bool would decode to false and clear - // the flag on every such edit -- silently, with a 200 response. Hence the *bool. - t.Run("an update that omits the field leaves it alone", func(t *testing.T) { - require.NoError(t, env.DB.Model(&programs.Program{}). - Where("id = ?", created.ID). - Update("has_program_completion", true).Error) - - withoutField := map[string]any{ - "name": "Flag Program", - "description": "edited without touching the flag", - "funding_type": programs.FederalGrants, - "is_active": true, - "program_types": []map[string]any{{"program_type": "Educational"}}, - "credit_types": []map[string]any{{"credit_type": "Completion"}}, - "facilities": []uint{facility.ID}, - } - NewRequest[programs.Program](env.Client, t, http.MethodPatch, - fmt.Sprintf("/api/programs/%d", created.ID), withoutField). - WithTestClaims(claims). - Do(). - ExpectStatus(http.StatusOK) - - var after programs.Program - require.NoError(t, env.DB.First(&after, created.ID).Error) - require.True(t, after.HasProgramCompletion, - "omitting has_program_completion must PRESERVE it, not clear it -- if this fails, "+ - "ProgramForm.HasProgramCompletion is a plain bool again and every edit from a "+ - "client that does not send the field wipes the flag") - }) - - t.Run("can be turned on again through update", func(t *testing.T) { - update := map[string]any{ - "name": "Flag Program", - "description": "program with its own completion", - "funding_type": programs.FederalGrants, - "is_active": true, - "has_program_completion": true, - "program_types": []map[string]any{{"program_type": "Educational"}}, - "credit_types": []map[string]any{{"credit_type": "Completion"}}, - "facilities": []uint{facility.ID}, - } - NewRequest[programs.Program](env.Client, t, http.MethodPatch, - fmt.Sprintf("/api/programs/%d", created.ID), update). - WithTestClaims(claims). - Do(). - ExpectStatus(http.StatusOK) - - var after programs.Program - require.NoError(t, env.DB.First(&after, created.ID).Error) - require.True(t, after.HasProgramCompletion, "setting the flag must persist") - }) -} diff --git a/backend/tests/integration/program_delete_guard_test.go b/backend/tests/integration/program_delete_guard_test.go deleted file mode 100644 index b7a06e1b3..000000000 --- a/backend/tests/integration/program_delete_guard_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/suite" -) - -type ProgramDeleteGuardTestSuite struct { - suite.Suite - env *TestEnv -} - -func (suite *ProgramDeleteGuardTestSuite) SetupSuite() { - suite.env = SetupTestEnv(suite.T()) -} - -func (suite *ProgramDeleteGuardTestSuite) TearDownSuite() { - suite.env.CleanupTestEnv() -} - -func (suite *ProgramDeleteGuardTestSuite) SetupTest() { - suite.env.DB.Exec("DELETE FROM program_class_event_attendance") - suite.env.DB.Exec("DELETE FROM program_class_events") - suite.env.DB.Exec("DELETE FROM program_class_enrollments") - suite.env.DB.Exec("DELETE FROM class_completions") - suite.env.DB.Exec("DELETE FROM change_log_entries") - suite.env.DB.Exec("DELETE FROM program_classes_history") - suite.env.DB.Exec("DELETE FROM program_class_cohorts") - suite.env.DB.Exec("DELETE FROM program_class_credit_types") - suite.env.DB.Exec("DELETE FROM program_classes") - suite.env.DB.Exec("DELETE FROM facilities_programs") - suite.env.DB.Exec("DELETE FROM programs") - suite.env.DB.Exec("DELETE FROM rooms") - suite.env.DB.Exec("DELETE FROM facilities") - suite.env.DB.Exec("DELETE FROM users WHERE role != 'system_admin'") -} - -func (suite *ProgramDeleteGuardTestSuite) seedEmptyProgram() (*models.User, *programs.Program, *models.Facility) { - ts := fmt.Sprintf("%d", time.Now().UnixNano()) - facility := &models.Facility{Name: "F " + ts} - suite.env.DB.Create(facility) - admin := &models.User{ - NameFirst: "A", NameLast: "A", - Username: "a" + ts, Email: "a" + ts + "@t.test", - Role: models.SystemAdmin, FacilityID: facility.ID, - } - suite.env.DB.Create(admin) - program := &programs.Program{Name: "P " + ts, IsActive: true, Description: "x"} - suite.env.DB.Create(program) - suite.env.DB.Create(&programs.FacilitiesPrograms{FacilityID: facility.ID, ProgramID: program.ID}) - return admin, program, facility -} - -func (suite *ProgramDeleteGuardTestSuite) TestDelete_EmptyProgram_Succeeds() { - admin, program, _ := suite.seedEmptyProgram() - NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/programs/%d", program.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). - Do(). - ExpectStatus(http.StatusNoContent) -} - -func (suite *ProgramDeleteGuardTestSuite) TestDelete_ProgramWithAnyClass_Blocked() { - admin, program, facility := suite.seedEmptyProgram() - class := &programs.ProgramClassCohort{ - ProgramID: program.ID, FacilityID: facility.ID, - Status: programs.Scheduled, Capacity: 10, Description: "x", - } - suite.env.DB.Create(class) - - resp := NewRequest[programs.DeleteBlockingChildren](suite.env.Client, suite.T(), - http.MethodDelete, fmt.Sprintf("/api/programs/%d", program.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). - Do(). - ExpectStatus(http.StatusConflict) - - blockers := resp.GetData() - suite.Equal(int64(1), blockers.Classes, "any class under the program blocks delete") - - var programCount int64 - suite.env.DB.Model(&programs.Program{}).Where("id = ?", program.ID).Count(&programCount) - suite.Equal(int64(1), programCount, "program should NOT be deleted") -} - -func (suite *ProgramDeleteGuardTestSuite) TestDelete_BlockedByClassWithEnrollment() { - admin, program, facility := suite.seedEmptyProgram() - class := &programs.ProgramClassCohort{ - ProgramID: program.ID, FacilityID: facility.ID, - Status: programs.Active, Capacity: 10, Description: "x", - } - suite.env.DB.Create(class) - student := &models.User{ - NameFirst: "S", NameLast: "S", - Username: fmt.Sprintf("s%d", time.Now().UnixNano()), - Email: fmt.Sprintf("s%d@t.test", time.Now().UnixNano()), - Role: models.Student, FacilityID: facility.ID, - } - suite.env.DB.Create(student) - suite.env.DB.Create(&programs.ProgramClassEnrollment{ - CohortID: class.ID, UserID: student.ID, EnrollmentStatus: programs.Enrolled, - }) - - resp := NewRequest[programs.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/programs/%d", program.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). - Do(). - ExpectStatus(http.StatusConflict) - - got := resp.GetData() - suite.Equal(int64(1), got.Classes, "should report 1 blocking class") - suite.Equal(int64(1), got.Enrollments) - - var count int64 - suite.env.DB.Model(&programs.Program{}).Where("id = ?", program.ID).Count(&count) - suite.Equal(int64(1), count, "program should still exist after blocked delete") -} - -func (suite *ProgramDeleteGuardTestSuite) TestDelete_BlockedAcrossMultipleClasses() { - admin, program, facility := suite.seedEmptyProgram() - c1 := &programs.ProgramClassCohort{ - ProgramID: program.ID, FacilityID: facility.ID, - Status: programs.Active, Capacity: 5, Description: "x", - } - c2 := &programs.ProgramClassCohort{ - ProgramID: program.ID, FacilityID: facility.ID, - Status: programs.Scheduled, Capacity: 5, Description: "x", - } - suite.env.DB.Create(c1) - suite.env.DB.Create(c2) - - student := &models.User{ - NameFirst: "S", NameLast: "S", - Username: fmt.Sprintf("s%d", time.Now().UnixNano()), - Email: fmt.Sprintf("s%d@t.test", time.Now().UnixNano()), - Role: models.Student, FacilityID: facility.ID, - } - suite.env.DB.Create(student) - suite.env.DB.Create(&programs.ProgramClassEnrollment{ - CohortID: c1.ID, UserID: student.ID, EnrollmentStatus: programs.Enrolled, - }) - - room := &programs.Room{FacilityID: facility.ID, Name: "101"} - suite.env.DB.Create(room) - instructor := &models.User{ - NameFirst: "Inst", NameLast: "Ructor", - Username: fmt.Sprintf("inst%d", time.Now().UnixNano()), - Email: fmt.Sprintf("inst%d@t.test", time.Now().UnixNano()), - Role: models.FacilityAdmin, FacilityID: facility.ID, - } - suite.env.DB.Create(instructor) - suite.env.DB.Create(&programs.ProgramClassEvent{ - CohortID: c2.ID, - Duration: "1h0m0s", - RecurrenceRule: "DTSTART:20260302T093000Z\nRRULE:FREQ=WEEKLY;COUNT=1", - RoomID: &room.ID, - InstructorID: &instructor.ID, - }) - - resp := NewRequest[programs.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/programs/%d", program.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). - Do(). - ExpectStatus(http.StatusConflict) - - got := resp.GetData() - suite.Equal(int64(2), got.Classes) - suite.Equal(int64(1), got.Enrollments) - suite.Equal(int64(1), got.Events) - - var count int64 - suite.env.DB.Model(&programs.Program{}).Where("id = ?", program.ID).Count(&count) - suite.Equal(int64(1), count, "program should still exist after blocked delete") -} - -func (suite *ProgramDeleteGuardTestSuite) TestDelete_HistoryOnly_Succeeds() { - admin, program, _ := suite.seedEmptyProgram() - - suite.env.DB.Create(programs.NewChangeLogEntry( - "programs", "name", - models.StringPtr("old"), models.StringPtr("new"), - program.ID, admin.ID, - )) - - NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, - fmt.Sprintf("/api/programs/%d", program.ID), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). - Do(). - ExpectStatus(http.StatusNoContent) - - var changeLogCount int64 - suite.env.DB.Table("change_log_entries"). - Where("table_name = 'programs' AND parent_ref_id = ?", program.ID). - Count(&changeLogCount) - suite.Equal(int64(0), changeLogCount, "change_log_entries for the program should be cleaned up") -} - -func TestProgramDeleteGuardTestSuite(t *testing.T) { - suite.Run(t, new(ProgramDeleteGuardTestSuite)) -} diff --git a/backend/tests/integration/program_insights_engagement_test.go b/backend/tests/integration/program_insights_engagement_test.go deleted file mode 100644 index 200a1d531..000000000 --- a/backend/tests/integration/program_insights_engagement_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package integration - -import ( - "net/http" - "strconv" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestProgramEngagementOverview(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Engagement Facility") - require.NoError(t, err) - - program, err := env.CreateTestProgram("GED Prep", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - class, err := env.CreateTestClass(program, facility, programs.Active, nil) - require.NoError(t, err) - - // 3 residents: one active, one completed-only (not active), one never enrolled - // (usernames must be alphanumunicode: no hyphens/underscores) - activeUser, err := env.CreateTestUser("activeres", models.Student, facility.ID, "") - require.NoError(t, err) - completedUser, err := env.CreateTestUser("completedres", models.Student, facility.ID, "") - require.NoError(t, err) - _, err = env.CreateTestUser("neverres", models.Student, facility.ID, "") - require.NoError(t, err) - - now := time.Now() - _, err = env.CreateTestEnrollmentWithDates(class.ID, activeUser.ID, programs.Enrolled, now, nil) - require.NoError(t, err) - // Completed well before the endpoint's default 30-day lookback window, so it - // falls into "previously engaged" rather than "active in this range". - completedEnrolledAt := now.AddDate(0, 0, -40) - completedEndedAt := now.AddDate(0, 0, -35) - _, err = env.CreateTestEnrollmentWithDates(class.ID, completedUser.ID, programs.EnrollmentCompleted, completedEnrolledAt, &completedEndedAt) - require.NoError(t, err) - - overview := NewRequest[programs.ProgramEngagementOverview](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/engagement-overview?facility="+strconv.Itoa(int(facility.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Equal(t, int64(3), overview.TotalResidents) - require.Equal(t, int64(1), overview.ActiveResidents) - require.Equal(t, int64(1), overview.PreviouslyEngagedResidents) - // never-engaged = residents with ZERO enrollment rows ever -> only "never-res" - require.Equal(t, int64(1), overview.NeverEngagedResidents) - require.Len(t, overview.TopPrograms, 0) // below the >=5-enrollee threshold, so excluded -} - -// enrollNResidents creates n Student residents in facility and enrolls each of them -// in classID with the given status, all with enrolled_at set to now (so they fall -// within the endpoint's default 30-day lookback window with no explicit date range). -func enrollNResidents(t *testing.T, env *TestEnv, facilityID uint, classID uint, usernamePrefix string, n int, status programs.ProgramEnrollmentStatus) { - t.Helper() - now := time.Now() - for i := 0; i < n; i++ { - user, err := env.CreateTestUser(usernamePrefix+strconv.Itoa(i), models.Student, facilityID, "") - require.NoError(t, err) - var endedAt *time.Time - if status != programs.Enrolled { - ended := now.Add(time.Hour) - endedAt = &ended - } - _, err = env.CreateTestEnrollmentWithDates(classID, user.ID, status, now, endedAt) - require.NoError(t, err) - } -} - -func TestProgramEngagementOverview_TopProgramsRanking(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Ranking Facility") - require.NoError(t, err) - - programA, err := env.CreateTestProgram("Program A", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - classA, err := env.CreateTestClass(programA, facility, programs.Active, nil) - require.NoError(t, err) - - programB, err := env.CreateTestProgram("Program B", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - classB, err := env.CreateTestClass(programB, facility, programs.Active, nil) - require.NoError(t, err) - - programC, err := env.CreateTestProgram("Program C", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - classC, err := env.CreateTestClass(programC, facility, programs.Active, nil) - require.NoError(t, err) - - // Program A: 5 enrollees, 4 completed, 1 still enrolled -> 80% completion, clears threshold. - enrollNResidents(t, env, facility.ID, classA.ID, "proga", 4, programs.EnrollmentCompleted) - enrollNResidents(t, env, facility.ID, classA.ID, "progaactive", 1, programs.Enrolled) - - // Program B: 5 enrollees, 1 completed, 4 still enrolled -> 20% completion, clears threshold. - enrollNResidents(t, env, facility.ID, classB.ID, "progb", 1, programs.EnrollmentCompleted) - enrollNResidents(t, env, facility.ID, classB.ID, "progbactive", 4, programs.Enrolled) - - // Program C: only 3 enrollees -> below the >=5 threshold, must be excluded entirely. - enrollNResidents(t, env, facility.ID, classC.ID, "progc", 3, programs.EnrollmentCompleted) - - overview := NewRequest[programs.ProgramEngagementOverview](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/engagement-overview?facility="+strconv.Itoa(int(facility.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Equal(t, []programs.ProgramCompletionRank{ - {ProgramName: "Program A", Enrolled: 5, Completed: 4, CompletionRate: 80}, - {ProgramName: "Program B", Enrolled: 5, Completed: 1, CompletionRate: 20}, - }, overview.TopPrograms) -} - -func TestProgramEngagementOverview_FacilityScopingIsolatesData(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facilityOne, err := env.CreateTestFacility("Scope Facility One") - require.NoError(t, err) - facilityTwo, err := env.CreateTestFacility("Scope Facility Two") - require.NoError(t, err) - - programOne, err := env.CreateTestProgram("Facility One Program", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - classOne, err := env.CreateTestClass(programOne, facilityOne, programs.Active, nil) - require.NoError(t, err) - - programTwo, err := env.CreateTestProgram("Facility Two Program", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - classTwo, err := env.CreateTestClass(programTwo, facilityTwo, programs.Active, nil) - require.NoError(t, err) - - // Facility one: 2 residents, 1 active enrollment, 1 never-engaged. - activeOne, err := env.CreateTestUser("factiveone", models.Student, facilityOne.ID, "") - require.NoError(t, err) - _, err = env.CreateTestUser("fneverone", models.Student, facilityOne.ID, "") - require.NoError(t, err) - _, err = env.CreateTestEnrollmentWithDates(classOne.ID, activeOne.ID, programs.Enrolled, time.Now(), nil) - require.NoError(t, err) - - // Facility two: 5 residents, all completed -- enough to clear the top-programs - // threshold, so if the facility filter leaks (e.g. wrong join column), this - // program would incorrectly surface in facility one's response. - enrollNResidents(t, env, facilityTwo.ID, classTwo.ID, "fleaktwo", 5, programs.EnrollmentCompleted) - - overview := NewRequest[programs.ProgramEngagementOverview](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/engagement-overview?facility="+strconv.Itoa(int(facilityOne.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, FacilityID: facilityOne.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Equal(t, int64(2), overview.TotalResidents) - require.Equal(t, int64(1), overview.ActiveResidents) - require.Equal(t, int64(0), overview.PreviouslyEngagedResidents) - require.Equal(t, int64(1), overview.NeverEngagedResidents) - require.Empty(t, overview.TopPrograms, "facility two's program must not leak into facility one's results") -} - -func TestProgramEngagementOverview_AllFacilitiesAggregates(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facilityOne, err := env.CreateTestFacility("All Facilities One") - require.NoError(t, err) - facilityTwo, err := env.CreateTestFacility("All Facilities Two") - require.NoError(t, err) - - programOne, err := env.CreateTestProgram("Program One", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - classOne, err := env.CreateTestClass(programOne, facilityOne, programs.Active, nil) - require.NoError(t, err) - - programTwo, err := env.CreateTestProgram("Program Two", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - classTwo, err := env.CreateTestClass(programTwo, facilityTwo, programs.Active, nil) - require.NoError(t, err) - - // Facility one: 6 residents -- 5 enrolled in Program One (3 completed, 2 still - // active), plus 1 who never enrolls. - enrollNResidents(t, env, facilityOne.ID, classOne.ID, "aone", 3, programs.EnrollmentCompleted) - enrollNResidents(t, env, facilityOne.ID, classOne.ID, "aoneactive", 2, programs.Enrolled) - _, err = env.CreateTestUser("aonenever", models.Student, facilityOne.ID, "") - require.NoError(t, err) - - // Facility two: 5 residents, all completed in Program Two. - enrollNResidents(t, env, facilityTwo.ID, classTwo.ID, "btwo", 5, programs.EnrollmentCompleted) - - overview := NewRequest[programs.ProgramEngagementOverview](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/engagement-overview?facility=all", nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facilityOne.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Equal(t, int64(11), overview.TotalResidents) - // All 10 enrollments (completed + still-enrolled) were created "now", so all - // fall within the endpoint's default 30-day lookback window and count as - // active-in-range, regardless of current status. - require.Equal(t, int64(10), overview.ActiveResidents) - require.Equal(t, int64(0), overview.PreviouslyEngagedResidents) - require.Equal(t, int64(1), overview.NeverEngagedResidents) - require.Equal(t, []programs.ProgramCompletionRank{ - {ProgramName: "Program Two", Enrolled: 5, Completed: 5, CompletionRate: 100}, - {ProgramName: "Program One", Enrolled: 5, Completed: 3, CompletionRate: 60}, - }, overview.TopPrograms) -} diff --git a/backend/tests/integration/program_insights_enrollment_by_type_test.go b/backend/tests/integration/program_insights_enrollment_by_type_test.go deleted file mode 100644 index 5181f01b2..000000000 --- a/backend/tests/integration/program_insights_enrollment_by_type_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package integration - -import ( - "net/http" - "strconv" - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// SystemAdmin can switch facilities, so getQueryContext defaults to -// statewide (facility_id 0) unless a facility is named explicitly - pass it -// via the "facility" param that resolveFacilityFilter reads. Same gotcha as -// the second-enrollment and completion-matrix tests. -func TestEnrollmentByProgramType(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Type Facility") - require.NoError(t, err) - - progA, err := env.CreateTestProgram("GED Prep", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - progB, err := env.CreateTestProgram("Welding", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Vocational}}, nil, true, nil) - require.NoError(t, err) - classA, err := env.CreateTestClass(progA, facility, programs.Active, nil) - require.NoError(t, err) - classB, err := env.CreateTestClass(progB, facility, programs.Active, nil) - require.NoError(t, err) - - u1, err := env.CreateTestUser("typeres1", models.Student, facility.ID, "") - require.NoError(t, err) - u2, err := env.CreateTestUser("typeres2", models.Student, facility.ID, "") - require.NoError(t, err) - - _, err = env.CreateTestEnrollment(classA.ID, u1.ID, programs.EnrollmentCompleted) - require.NoError(t, err) - _, err = env.CreateTestEnrollment(classA.ID, u2.ID, programs.Enrolled) - require.NoError(t, err) - _, err = env.CreateTestEnrollment(classB.ID, u1.ID, programs.Enrolled) - require.NoError(t, err) - - rows := NewRequest[[]programs.ProgramTypeEnrollment](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/enrollment-by-type?facility="+strconv.Itoa(int(facility.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - byType := map[string]programs.ProgramTypeEnrollment{} - for _, row := range rows { - byType[row.ProgramType] = row - } - require.Equal(t, int64(2), byType["Educational"].Enrolled) - require.Equal(t, int64(1), byType["Educational"].Completed) - require.Equal(t, int64(1), byType["Vocational"].Enrolled) - require.Equal(t, int64(0), byType["Vocational"].Completed) -} - -// TestEnrollmentByProgramTypeAllFacilities exercises the facilityID == nil -// (statewide) path, which is this endpoint's primary/default use case per -// its name - not an edge case. -func TestEnrollmentByProgramTypeAllFacilities(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facilityOne, err := env.CreateTestFacility("Type Facility One") - require.NoError(t, err) - facilityTwo, err := env.CreateTestFacility("Type Facility Two") - require.NoError(t, err) - - prog, err := env.CreateTestProgram("GED Prep", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - classOne, err := env.CreateTestClass(prog, facilityOne, programs.Active, nil) - require.NoError(t, err) - classTwo, err := env.CreateTestClass(prog, facilityTwo, programs.Active, nil) - require.NoError(t, err) - - u1, err := env.CreateTestUser("alltyperes1", models.Student, facilityOne.ID, "") - require.NoError(t, err) - u2, err := env.CreateTestUser("alltyperes2", models.Student, facilityTwo.ID, "") - require.NoError(t, err) - - _, err = env.CreateTestEnrollment(classOne.ID, u1.ID, programs.EnrollmentCompleted) - require.NoError(t, err) - _, err = env.CreateTestEnrollment(classTwo.ID, u2.ID, programs.Enrolled) - require.NoError(t, err) - - rows := NewRequest[[]programs.ProgramTypeEnrollment](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/enrollment-by-type?facility=all", nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facilityOne.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Len(t, rows, 1) - require.Equal(t, "Educational", rows[0].ProgramType) - require.Equal(t, int64(2), rows[0].Enrolled) - require.Equal(t, int64(1), rows[0].Completed) - require.InDelta(t, 50.0, rows[0].Rate, 0.01) -} - -// TestEnrollmentByProgramTypeCapping exercises the top-4 + "Other" capping -// logic: with 6 distinct program types enrolled, only the 4 highest- -// enrollment individual types are returned as-is, and the remaining 2 are -// rolled up into a single "Other" bucket whose enrolled/completed counts are -// the sum of the rolled-up types and whose rate is recomputed from those sums. -func TestEnrollmentByProgramTypeCapping(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Capping Facility") - require.NoError(t, err) - - allTypes := []programs.ProgType{ - programs.Educational, programs.Vocational, programs.MentalHealth, - programs.Religious, programs.ReEntry, programs.Therapeutic, - } - - type classInfo struct { - class *programs.ProgramClassCohort - enrollees int - completes int - } - // Give each type a distinct enrollment count so ranking is unambiguous: - // Educational=6, Vocational=5, MentalHealth=4, Religious=3, ReEntry=2, Therapeutic=1. - // ReEntry and Therapeutic (the two rolled into "Other") are given - // deliberately different individual rates - ReEntry 2/2=100%, - // Therapeutic 1/0=0% - so a buggy average-of-rates implementation - // ((100+0)/2=50%) would diverge from the correct sum-then-recompute - // result (2/3=66.67%) and the assertion below would catch it. - counts := []int{6, 5, 4, 3, 2, 1} - completes := []int{3, 2, 1, 1, 2, 0} - - classes := make([]classInfo, 0, len(allTypes)) - for i, pt := range allTypes { - prog, err := env.CreateTestProgram(string(pt)+" Program", programs.StateGrants, - []programs.ProgramType{{ProgramType: pt}}, nil, true, nil) - require.NoError(t, err) - class, err := env.CreateTestClass(prog, facility, programs.Active, nil) - require.NoError(t, err) - classes = append(classes, classInfo{class: class, enrollees: counts[i], completes: completes[i]}) - } - - userN := 0 - for _, ci := range classes { - for i := 0; i < ci.enrollees; i++ { - userN++ - user, err := env.CreateTestUser("capres"+strconv.Itoa(userN), models.Student, facility.ID, "") - require.NoError(t, err) - status := programs.Enrolled - if i < ci.completes { - status = programs.EnrollmentCompleted - } - _, err = env.CreateTestEnrollment(ci.class.ID, user.ID, status) - require.NoError(t, err) - } - } - - rows := NewRequest[[]programs.ProgramTypeEnrollment](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/enrollment-by-type?facility="+strconv.Itoa(int(facility.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Len(t, rows, 5) - - byType := map[string]programs.ProgramTypeEnrollment{} - for _, row := range rows { - byType[row.ProgramType] = row - } - - // Top 4 by enrollment: Educational(6), Vocational(5), Mental Health/Behavioral(4), Religious/Faith-Based(3). - require.Equal(t, int64(6), byType["Educational"].Enrolled) - require.Equal(t, int64(5), byType["Vocational"].Enrolled) - require.Equal(t, int64(4), byType["Mental Health/Behavioral"].Enrolled) - require.Equal(t, int64(3), byType["Religious/Faith-Based"].Enrolled) - - // Rolled up: ReEntry(2 enrolled, 2 completed) + Therapeutic(1 enrolled, 0 completed). - // Sum-then-recompute gives 2/3*100=66.67%; a buggy average-of-rates - // would give (100+0)/2=50%, so this assertion distinguishes the two. - other, ok := byType["Other"] - require.True(t, ok, "expected an Other bucket for the rolled-up remainder") - require.Equal(t, int64(3), other.Enrolled) - require.Equal(t, int64(2), other.Completed) - require.InDelta(t, 66.67, other.Rate, 0.01) -} - -// TestEnrollmentByProgramTypeNoCappingAtBoundary exercises the len(all) <= -// maxIndividualProgramTypes boundary with exactly 4 program types: no -// capping should occur and no "Other" row should appear. -func TestEnrollmentByProgramTypeNoCappingAtBoundary(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Boundary Facility") - require.NoError(t, err) - - fourTypes := []programs.ProgType{ - programs.Educational, programs.Vocational, programs.MentalHealth, programs.Religious, - } - for i, pt := range fourTypes { - prog, err := env.CreateTestProgram(string(pt)+" Program", programs.StateGrants, - []programs.ProgramType{{ProgramType: pt}}, nil, true, nil) - require.NoError(t, err) - class, err := env.CreateTestClass(prog, facility, programs.Active, nil) - require.NoError(t, err) - user, err := env.CreateTestUser("boundres"+strconv.Itoa(i), models.Student, facility.ID, "") - require.NoError(t, err) - _, err = env.CreateTestEnrollment(class.ID, user.ID, programs.Enrolled) - require.NoError(t, err) - } - - rows := NewRequest[[]programs.ProgramTypeEnrollment](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/enrollment-by-type?facility="+strconv.Itoa(int(facility.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Len(t, rows, 4) - for _, row := range rows { - require.NotEqual(t, "Other", row.ProgramType) - } -} diff --git a/backend/tests/integration/program_insights_load_test.go b/backend/tests/integration/program_insights_load_test.go deleted file mode 100644 index de61525f9..000000000 --- a/backend/tests/integration/program_insights_load_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package integration - -import ( - "net/http" - "strconv" - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestProgramLoadDistribution(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Load Facility") - require.NoError(t, err) - - progA, err := env.CreateTestProgram("Program A", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - progB, err := env.CreateTestProgram("Program B", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Vocational}}, nil, true, nil) - require.NoError(t, err) - classA, err := env.CreateTestClass(progA, facility, programs.Active, nil) - require.NoError(t, err) - classB, err := env.CreateTestClass(progB, facility, programs.Active, nil) - require.NoError(t, err) - - zeroUser, err := env.CreateTestUser("loadzero", models.Student, facility.ID, "") - require.NoError(t, err) - _ = zeroUser - oneUser, err := env.CreateTestUser("loadone", models.Student, facility.ID, "") - require.NoError(t, err) - twoUser, err := env.CreateTestUser("loadtwo", models.Student, facility.ID, "") - require.NoError(t, err) - - _, err = env.CreateTestEnrollment(classA.ID, oneUser.ID, programs.Enrolled) - require.NoError(t, err) - _, err = env.CreateTestEnrollment(classA.ID, twoUser.ID, programs.Enrolled) - require.NoError(t, err) - _, err = env.CreateTestEnrollment(classB.ID, twoUser.ID, programs.Enrolled) - require.NoError(t, err) - - dist := NewRequest[programs.ProgramLoadDistribution](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/load-distribution?facility="+strconv.Itoa(int(facility.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - byBucket := map[string]int64{} - for _, b := range dist.Statewide { - byBucket[b.Bucket] = b.Count - } - require.Equal(t, int64(1), byBucket["0"]) - require.Equal(t, int64(1), byBucket["1"]) - require.Equal(t, int64(1), byBucket["2"]) - - require.Len(t, dist.ByFacility, 1) - row := dist.ByFacility[0] - require.Equal(t, "Load Facility", row.FacilityName) - require.Equal(t, int64(1), row.Zero) - require.Equal(t, int64(1), row.One) - require.Equal(t, int64(1), row.Two) - require.Equal(t, int64(3), row.Total) -} - -// TestProgramLoadDistributionAllFacilities exercises the facilityID == nil -// (statewide) path, which is this endpoint's highest-risk branch: the -// resident population and by-facility attribution are scoped by each -// resident's HOME facility (u.facility_id), while the active-enrollment -// COUNT per resident is joined through the enrollment's cohort facility -// (pc.facility_id). This test deliberately enrolls a resident whose home -// facility differs from the cohort facility of the class they're enrolled -// in, to prove the by-facility breakdown attributes them to their home -// facility (not the cohort's facility) even when no facility filter is -// applied to the counts query. -func TestProgramLoadDistributionAllFacilities(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facilityOne, err := env.CreateTestFacility("Facility One") - require.NoError(t, err) - facilityTwo, err := env.CreateTestFacility("Facility Two") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Cross Facility Program", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - class, err := env.CreateTestClass(program, facilityOne, programs.Active, nil) - require.NoError(t, err) - - homeUser, err := env.CreateTestUser("loadhomeone", models.Student, facilityOne.ID, "") - require.NoError(t, err) - _, err = env.CreateTestEnrollment(class.ID, homeUser.ID, programs.Enrolled) - require.NoError(t, err) - - crossUser, err := env.CreateTestUser("loadcrosstwo", models.Student, facilityTwo.ID, "") - require.NoError(t, err) - _, err = env.CreateTestEnrollment(class.ID, crossUser.ID, programs.Enrolled) - require.NoError(t, err) - - idleUser, err := env.CreateTestUser("loadidletwo", models.Student, facilityTwo.ID, "") - require.NoError(t, err) - _ = idleUser - - dist := NewRequest[programs.ProgramLoadDistribution](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/load-distribution?facility=all", nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facilityOne.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - byBucket := map[string]int64{} - for _, b := range dist.Statewide { - byBucket[b.Bucket] = b.Count - } - require.Equal(t, int64(1), byBucket["0"]) - require.Equal(t, int64(2), byBucket["1"]) - - require.Len(t, dist.ByFacility, 2) - byName := map[string]programs.ProgramLoadFacilityRow{} - for _, row := range dist.ByFacility { - byName[row.FacilityName] = row - } - - one := byName["Facility One"] - require.Equal(t, int64(0), one.Zero) - require.Equal(t, int64(1), one.One) - require.Equal(t, int64(1), one.Total) - - two := byName["Facility Two"] - require.Equal(t, int64(1), two.Zero) - require.Equal(t, int64(1), two.One) - require.Equal(t, int64(2), two.Total) -} diff --git a/backend/tests/integration/program_insights_matrix_test.go b/backend/tests/integration/program_insights_matrix_test.go deleted file mode 100644 index 20cd9f563..000000000 --- a/backend/tests/integration/program_insights_matrix_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package integration - -import ( - "net/http" - "strconv" - "testing" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestProgramCompletionMatrix(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Matrix Facility") - require.NoError(t, err) - - program, err := env.CreateTestProgram("GED Prep", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Educational}}, nil, true, nil) - require.NoError(t, err) - class, err := env.CreateTestClass(program, facility, programs.Active, nil) - require.NoError(t, err) - - // 3 enrollees, 1 completed -> enrolled=3 meets the n>=3 threshold - for i := 0; i < 2; i++ { - u, err := env.CreateTestUser("matrixres"+string(rune('a'+i)), models.Student, facility.ID, "") - require.NoError(t, err) - _, err = env.CreateTestEnrollment(class.ID, u.ID, programs.Enrolled) - require.NoError(t, err) - } - completedUser, err := env.CreateTestUser("matrixrescompleted", models.Student, facility.ID, "") - require.NoError(t, err) - _, err = env.CreateTestEnrollment(class.ID, completedUser.ID, programs.EnrollmentCompleted) - require.NoError(t, err) - - // A second program in the SAME facility, with only 2 enrollees (below the - // n>=3 threshold) and 1 completion, both to exercise the "insufficient" - // branch distinctly AND to give the facility average a second, different - // cell so the delta calculation is a genuine average, not a trivial - // single-cell equivalence. - smallProgram, err := env.CreateTestProgram("Financial Literacy", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.LifeSkills}}, nil, true, nil) - require.NoError(t, err) - smallClass, err := env.CreateTestClass(smallProgram, facility, programs.Active, nil) - require.NoError(t, err) - - smallCompletedUser, err := env.CreateTestUser("matrixressmall", models.Student, facility.ID, "") - require.NoError(t, err) - _, err = env.CreateTestEnrollment(smallClass.ID, smallCompletedUser.ID, programs.EnrollmentCompleted) - require.NoError(t, err) - smallEnrolledUser, err := env.CreateTestUser("matrixressmallb", models.Student, facility.ID, "") - require.NoError(t, err) - _, err = env.CreateTestEnrollment(smallClass.ID, smallEnrolledUser.ID, programs.Enrolled) - require.NoError(t, err) - - cells := NewRequest[[]programs.ProgramCompletionMatrixCell](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/completion-matrix?facility="+strconv.Itoa(int(facility.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Len(t, cells, 2) - - var gedCell, smallCell programs.ProgramCompletionMatrixCell - for _, c := range cells { - switch c.ProgramName { - case "GED Prep": - gedCell = c - case "Financial Literacy": - smallCell = c - } - } - - require.Equal(t, "Matrix Facility", gedCell.FacilityName) - require.Equal(t, int64(3), gedCell.Enrolled) - require.Equal(t, int64(1), gedCell.Completed) - require.False(t, gedCell.Insufficient) - require.InDelta(t, 33.33, gedCell.CompletionRate, 0.01) - - require.Equal(t, "Matrix Facility", smallCell.FacilityName) - require.Equal(t, int64(2), smallCell.Enrolled) - require.Equal(t, int64(1), smallCell.Completed) - require.True(t, smallCell.Insufficient) - require.Equal(t, 0.0, smallCell.CompletionRate) - require.Equal(t, 0.0, smallCell.DeltaFromFacilityAverage) - - // Facility average across both cells: (1 + 1) completed / (3 + 2) enrolled = 40%. - // GED Prep's own rate is 33.33%, so its delta should be 33.33 - 40 = -6.67, - // not 0 - this is the assertion that actually proves the delta is computed - // relative to a genuine multi-cell facility average. - require.InDelta(t, -6.67, gedCell.DeltaFromFacilityAverage, 0.01) -} diff --git a/backend/tests/integration/program_insights_second_enrollment_test.go b/backend/tests/integration/program_insights_second_enrollment_test.go deleted file mode 100644 index 6781e8853..000000000 --- a/backend/tests/integration/program_insights_second_enrollment_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package integration - -import ( - "net/http" - "strconv" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestSecondProgramEnrollment(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Second Enrollment Facility") - require.NoError(t, err) - - progA, err := env.CreateTestProgram("Welding", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.Vocational}}, nil, true, nil) - require.NoError(t, err) - progB, err := env.CreateTestProgram("Financial Literacy", programs.StateGrants, - []programs.ProgramType{{ProgramType: programs.LifeSkills}}, nil, true, nil) - require.NoError(t, err) - classA, err := env.CreateTestClass(progA, facility, programs.Active, nil) - require.NoError(t, err) - classB, err := env.CreateTestClass(progB, facility, programs.Active, nil) - require.NoError(t, err) - - user, err := env.CreateTestUser("repeatres", models.Student, facility.ID, "") - require.NoError(t, err) - otherUser, err := env.CreateTestUser("onceres", models.Student, facility.ID, "") - require.NoError(t, err) - earlyUser, err := env.CreateTestUser("earlyres", models.Student, facility.ID, "") - require.NoError(t, err) - sameProgramUser, err := env.CreateTestUser("sameprogres", models.Student, facility.ID, "") - require.NoError(t, err) - - completedAt := time.Now().Add(-48 * time.Hour) - // user completes Vocational, then enrolls in LifeSkills AFTER completion -> counts - _, err = env.CreateTestEnrollmentWithDates(classA.ID, user.ID, programs.EnrollmentCompleted, - completedAt.Add(-time.Hour), &completedAt) - require.NoError(t, err) - _, err = env.CreateTestEnrollmentWithDates(classB.ID, user.ID, programs.Enrolled, - completedAt.Add(time.Hour), nil) - require.NoError(t, err) - - // otherUser completes Vocational but never enrolls again -> does not count - otherCompletedAt := time.Now().Add(-24 * time.Hour) - _, err = env.CreateTestEnrollmentWithDates(classA.ID, otherUser.ID, programs.EnrollmentCompleted, - otherCompletedAt.Add(-time.Hour), &otherCompletedAt) - require.NoError(t, err) - - // earlyUser enrolls in LifeSkills BEFORE completing Vocational -> the LifeSkills - // enrollment predates the completion, so it must NOT count as a "second enrollment" - // triggered by that completion. This is the chronological-ordering fix under test: - // a buggy implementation that ignores order would incorrectly count this as a hit. - earlyCompletedAt := time.Now().Add(-12 * time.Hour) - _, err = env.CreateTestEnrollmentWithDates(classB.ID, earlyUser.ID, programs.Enrolled, - earlyCompletedAt.Add(-time.Hour), nil) - require.NoError(t, err) - _, err = env.CreateTestEnrollmentWithDates(classA.ID, earlyUser.ID, programs.EnrollmentCompleted, - earlyCompletedAt.Add(-30*time.Minute), &earlyCompletedAt) - require.NoError(t, err) - - // sameProgramUser completes Vocational, then re-enrolls in Vocational again - // (same program, not a different one) -> must NOT count as a "second - // program" enrollment, even though it starts after the first completion. - sameProgramCompletedAt := time.Now().Add(-6 * time.Hour) - _, err = env.CreateTestEnrollmentWithDates(classA.ID, sameProgramUser.ID, programs.EnrollmentCompleted, - sameProgramCompletedAt.Add(-time.Hour), &sameProgramCompletedAt) - require.NoError(t, err) - _, err = env.CreateTestEnrollmentWithDates(classA.ID, sameProgramUser.ID, programs.Enrolled, - sameProgramCompletedAt.Add(time.Hour), nil) - require.NoError(t, err) - - // SystemAdmin can switch facilities, so getQueryContext defaults to - // statewide (facility_id 0) unless a facility is named explicitly - - // pass it via the "facility" param that resolveFacilityFilter reads. - rows := NewRequest[[]programs.SecondProgramEnrollmentRow](env.Client, t, http.MethodGet, - "/api/department-metrics/programs/second-enrollment?facility="+strconv.Itoa(int(facility.ID)), nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - GetData() - - require.Len(t, rows, 1) - row := rows[0] - require.Equal(t, "Second Enrollment Facility", row.FacilityName) - require.Equal(t, "Vocational", row.ProgramType) - require.Equal(t, int64(4), row.CompletedFirst) - require.Equal(t, int64(1), row.EnrolledSecond) - require.InDelta(t, 25.0, row.Rate, 0.01) -} diff --git a/backend/tests/integration/program_outcomes_report_test.go b/backend/tests/integration/program_outcomes_report_test.go deleted file mode 100644 index b9046d5e7..000000000 --- a/backend/tests/integration/program_outcomes_report_test.go +++ /dev/null @@ -1,531 +0,0 @@ -package integration - -import ( - "fmt" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -type ProgramOutcomesReportTestSuite struct { - suite.Suite - env *TestEnv -} - -func (suite *ProgramOutcomesReportTestSuite) SetupSuite() { - suite.env = SetupTestEnv(suite.T()) -} - -func (suite *ProgramOutcomesReportTestSuite) TearDownSuite() { - suite.env.CleanupTestEnv() -} - -func (suite *ProgramOutcomesReportTestSuite) SetupTest() { - suite.cleanDatabase() -} - -func (suite *ProgramOutcomesReportTestSuite) cleanDatabase() { - suite.env.DB.Exec("DELETE FROM program_class_event_attendance") - suite.env.DB.Exec("DELETE FROM program_class_events") - suite.env.DB.Exec("DELETE FROM program_class_enrollments") - suite.env.DB.Exec("DELETE FROM program_class_cohorts") - suite.env.DB.Exec("DELETE FROM program_class_credit_types") - suite.env.DB.Exec("DELETE FROM program_classes") - suite.env.DB.Exec("DELETE FROM program_types") - suite.env.DB.Exec("DELETE FROM facilities_programs") - suite.env.DB.Exec("DELETE FROM programs") - suite.env.DB.Exec("DELETE FROM rooms") - suite.env.DB.Exec("DELETE FROM facilities") - suite.env.DB.Exec("DELETE FROM users WHERE role != 'system_admin'") -} - -func (suite *ProgramOutcomesReportTestSuite) TestClassStatusFilter_Active() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - - activeClass := suite.createClass("Active Class", program.ID, facility.ID, programs.Active) - pausedClass := suite.createClass("Paused Class", program.ID, facility.ID, programs.Paused) - - student1 := suite.createStudent("Student", "One", facility.ID) - student2 := suite.createStudent("Student", "Two", facility.ID) - - now := time.Now() - suite.createEnrollment(activeClass.ID, student1.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(pausedClass.ID, student2.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - ClassStatus: stringPtr("Active"), - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1) - assert.Equal(suite.T(), 1, results[0].TotalEnrollments) - assert.Equal(suite.T(), 1, results[0].ActiveEnrollments) -} - -func (suite *ProgramOutcomesReportTestSuite) TestClassStatusFilter_NotActive() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - - activeClass := suite.createClass("Active Class", program.ID, facility.ID, programs.Active) - completedClass := suite.createClass("Completed Class", program.ID, facility.ID, programs.Completed) - cancelledClass := suite.createClass("Cancelled Class", program.ID, facility.ID, programs.Cancelled) - - student1 := suite.createStudent("Student", "One", facility.ID) - student2 := suite.createStudent("Student", "Two", facility.ID) - student3 := suite.createStudent("Student", "Three", facility.ID) - - now := time.Now() - suite.createEnrollment(activeClass.ID, student1.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(completedClass.ID, student2.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(cancelledClass.ID, student3.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - ClassStatus: stringPtr("Not Active"), - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1) - assert.Equal(suite.T(), 2, results[0].TotalEnrollments) -} - -func (suite *ProgramOutcomesReportTestSuite) TestClassStatusFilter_All() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - - activeClass := suite.createClass("Active Class", program.ID, facility.ID, programs.Active) - pausedClass := suite.createClass("Paused Class", program.ID, facility.ID, programs.Paused) - - student1 := suite.createStudent("Student", "One", facility.ID) - student2 := suite.createStudent("Student", "Two", facility.ID) - - now := time.Now() - suite.createEnrollment(activeClass.ID, student1.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(pausedClass.ID, student2.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - ClassStatus: stringPtr("All"), - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1) - assert.Equal(suite.T(), 2, results[0].TotalEnrollments) -} - -func (suite *ProgramOutcomesReportTestSuite) TestClassStatusFilter_DefaultsToActive() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - - activeClass := suite.createClass("Active Class", program.ID, facility.ID, programs.Active) - pausedClass := suite.createClass("Paused Class", program.ID, facility.ID, programs.Paused) - - student1 := suite.createStudent("Student", "One", facility.ID) - student2 := suite.createStudent("Student", "Two", facility.ID) - - now := time.Now() - suite.createEnrollment(activeClass.ID, student1.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(pausedClass.ID, student2.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - ClassStatus: nil, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1) - assert.Equal(suite.T(), 1, results[0].TotalEnrollments) -} - -func (suite *ProgramOutcomesReportTestSuite) TestMultipleProgramTypes_NoDuplicateRows() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - - suite.addProgramType(program.ID, programs.Educational) - suite.addProgramType(program.ID, programs.Vocational) - - activeClass := suite.createClass("Active Class", program.ID, facility.ID, programs.Active) - - student1 := suite.createStudent("Student", "One", facility.ID) - student2 := suite.createStudent("Student", "Two", facility.ID) - - now := time.Now() - suite.createEnrollment(activeClass.ID, student1.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(activeClass.ID, student2.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1, "Should return single row even with multiple program types") - assert.Equal(suite.T(), 2, results[0].TotalEnrollments) - assert.Contains(suite.T(), results[0].ProgramType, "Educational") - assert.Contains(suite.T(), results[0].ProgramType, "Vocational") -} - -func (suite *ProgramOutcomesReportTestSuite) TestEnrollmentDateFiltering() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - activeClass := suite.createClass("Active Class", program.ID, facility.ID, programs.Active) - - student1 := suite.createStudent("Student", "One", facility.ID) - student2 := suite.createStudent("Student", "Two", facility.ID) - student3 := suite.createStudent("Student", "Three", facility.ID) - - now := time.Now() - reportStart := now.AddDate(0, -1, 0) - reportEnd := now - - suite.createEnrollment(activeClass.ID, student1.ID, now.AddDate(0, -2, 0), programs.Enrolled) - suite.createEnrollment(activeClass.ID, student2.ID, now.AddDate(0, -1, 5), programs.Enrolled) - suite.createEnrollment(activeClass.ID, student3.ID, now.AddDate(0, 1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: reportStart, - EndDate: reportEnd, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1) - // Windowed: only the enrollment whose enrolled_at falls within - // [reportStart, reportEnd] counts. The one before the window and the - // future one are both excluded. - assert.Equal(suite.T(), 1, results[0].TotalEnrollments, "Only enrollments within the window should count") -} - -func (suite *ProgramOutcomesReportTestSuite) TestEnrollmentStatusCounts() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - activeClass := suite.createClass("Active Class", program.ID, facility.ID, programs.Active) - - students := make([]*models.User, 10) - for i := 0; i < 10; i++ { - students[i] = suite.createStudent("Student", fmt.Sprintf("%d", i), facility.ID) - } - - now := time.Now() - - suite.createEnrollment(activeClass.ID, students[0].ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(activeClass.ID, students[1].ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(activeClass.ID, students[2].ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(activeClass.ID, students[3].ID, now.AddDate(0, -1, 0), programs.EnrollmentCompleted) - suite.createEnrollment(activeClass.ID, students[4].ID, now.AddDate(0, -1, 0), programs.EnrollmentCompleted) - suite.createEnrollment(activeClass.ID, students[5].ID, now.AddDate(0, -1, 0), programs.EnrollmentIncompleteDropped) - suite.createEnrollment(activeClass.ID, students[6].ID, now.AddDate(0, -1, 0), programs.EnrollmentIncompleteDropped) - suite.createEnrollment(activeClass.ID, students[7].ID, now.AddDate(0, -1, 0), programs.EnrollmentIncompleteDropped) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1) - // All-time enrolled counts every status; currently enrolled counts only - // the "Enrolled" status. Completed/dropped are no longer broken out as - // separate columns in this report. - assert.Equal(suite.T(), 8, results[0].TotalEnrollments, "All-time enrolled should include all statuses") - assert.Equal(suite.T(), 3, results[0].ActiveEnrollments, "Currently enrolled should only include Enrolled status") -} - -func (suite *ProgramOutcomesReportTestSuite) TestAttendanceDoesNotAffectEnrollmentCounts() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - activeClass := suite.createClass("Active Class", program.ID, facility.ID, programs.Active) - - student1 := suite.createStudent("Student", "One", facility.ID) - student2 := suite.createStudent("Student", "Two", facility.ID) - - now := time.Now() - suite.createEnrollment(activeClass.ID, student1.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(activeClass.ID, student2.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - event1 := suite.createEvent(activeClass.ID, now.AddDate(0, 0, -5)) - event2 := suite.createEvent(activeClass.ID, now.AddDate(0, 0, -4)) - event3 := suite.createEvent(activeClass.ID, now.AddDate(0, 0, -3)) - - suite.createAttendance(event1.ID, student1.ID, programs.Present) - suite.createAttendance(event2.ID, student1.ID, programs.Present) - suite.createAttendance(event3.ID, student1.ID, programs.Present) - suite.createAttendance(event1.ID, student2.ID, programs.Absent_Unexcused) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1) - assert.Equal(suite.T(), 2, results[0].TotalEnrollments, "Enrollment count should not be affected by attendance records") - assert.Equal(suite.T(), 2, results[0].ActiveEnrollments) -} - -func (suite *ProgramOutcomesReportTestSuite) TestMultipleClassesInSameProgram() { - facility := suite.createFacility("Test Facility") - program := suite.createProgram("Test Program", facility.ID, programs.StateGrants) - - class1 := suite.createClass("Class 1", program.ID, facility.ID, programs.Active) - class2 := suite.createClass("Class 2", program.ID, facility.ID, programs.Active) - - students := make([]*models.User, 9) - for i := 0; i < 9; i++ { - students[i] = suite.createStudent("Student", fmt.Sprintf("%d", i), facility.ID) - } - - now := time.Now() - - for i := 0; i < 5; i++ { - suite.createEnrollment(class1.ID, students[i].ID, now.AddDate(0, -1, 0), programs.Enrolled) - } - for i := 5; i < 9; i++ { - suite.createEnrollment(class2.ID, students[i].ID, now.AddDate(0, -1, 0), programs.Enrolled) - } - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1) - assert.Equal(suite.T(), 9, results[0].TotalEnrollments) -} - -func (suite *ProgramOutcomesReportTestSuite) TestFacilityIDsFilter_MultipleFacilities() { - facilityA := suite.createFacility("Facility A") - facilityB := suite.createFacility("Facility B") - facilityC := suite.createFacility("Facility C") - - programA := suite.createProgram("Program A", facilityA.ID, programs.StateGrants) - programB := suite.createProgram("Program B", facilityB.ID, programs.StateGrants) - programC := suite.createProgram("Program C", facilityC.ID, programs.StateGrants) - - classA := suite.createClass("Class A", programA.ID, facilityA.ID, programs.Active) - classB := suite.createClass("Class B", programB.ID, facilityB.ID, programs.Active) - classC := suite.createClass("Class C", programC.ID, facilityC.ID, programs.Active) - - studentA := suite.createStudent("A", "Student", facilityA.ID) - studentB := suite.createStudent("B", "Student", facilityB.ID) - studentC := suite.createStudent("C", "Student", facilityC.ID) - - now := time.Now() - suite.createEnrollment(classA.ID, studentA.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(classB.ID, studentB.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(classC.ID, studentC.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - FacilityIDs: []uint{facilityA.ID, facilityB.ID}, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 2, "Should include only the two requested facilities' programs") - names := []string{results[0].ProgramName, results[1].ProgramName} - assert.Contains(suite.T(), names, "Program A") - assert.Contains(suite.T(), names, "Program B") - assert.NotContains(suite.T(), names, "Program C") -} - -func (suite *ProgramOutcomesReportTestSuite) TestFacilityIDFilter_SingleFacility() { - facilityA := suite.createFacility("Facility A") - facilityB := suite.createFacility("Facility B") - - programA := suite.createProgram("Program A", facilityA.ID, programs.StateGrants) - programB := suite.createProgram("Program B", facilityB.ID, programs.StateGrants) - - classA := suite.createClass("Class A", programA.ID, facilityA.ID, programs.Active) - classB := suite.createClass("Class B", programB.ID, facilityB.ID, programs.Active) - - studentA := suite.createStudent("A", "Student", facilityA.ID) - studentB := suite.createStudent("B", "Student", facilityB.ID) - - now := time.Now() - suite.createEnrollment(classA.ID, studentA.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(classB.ID, studentB.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - FacilityID: &facilityA.ID, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 1, "Single facility_id should scope to one facility") - assert.Equal(suite.T(), "Program A", results[0].ProgramName) -} - -func (suite *ProgramOutcomesReportTestSuite) TestProgramIDsFilter_MultiplePrograms() { - facility := suite.createFacility("Test Facility") - - programA := suite.createProgram("Program A", facility.ID, programs.StateGrants) - programB := suite.createProgram("Program B", facility.ID, programs.StateGrants) - programC := suite.createProgram("Program C", facility.ID, programs.StateGrants) - - classA := suite.createClass("Class A", programA.ID, facility.ID, programs.Active) - classB := suite.createClass("Class B", programB.ID, facility.ID, programs.Active) - classC := suite.createClass("Class C", programC.ID, facility.ID, programs.Active) - - studentA := suite.createStudent("A", "Student", facility.ID) - studentB := suite.createStudent("B", "Student", facility.ID) - studentC := suite.createStudent("C", "Student", facility.ID) - - now := time.Now() - suite.createEnrollment(classA.ID, studentA.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(classB.ID, studentB.ID, now.AddDate(0, -1, 0), programs.Enrolled) - suite.createEnrollment(classC.ID, studentC.ID, now.AddDate(0, -1, 0), programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: now.AddDate(0, -2, 0), - EndDate: now, - ProgramIDs: []uint{programA.ID, programB.ID}, - } - - results, err := suite.env.Programs.GenerateProgramOutcomesReport(suite.env.Context, req) - assert.NoError(suite.T(), err) - assert.Len(suite.T(), results, 2, "Should include only the two requested programs") - names := []string{results[0].ProgramName, results[1].ProgramName} - assert.Contains(suite.T(), names, "Program A") - assert.Contains(suite.T(), names, "Program B") - assert.NotContains(suite.T(), names, "Program C") -} - -func (suite *ProgramOutcomesReportTestSuite) createFacility(name string) *models.Facility { - facility := &models.Facility{Name: name} - suite.env.DB.Create(facility) - return facility -} - -func (suite *ProgramOutcomesReportTestSuite) createProgram(name string, facilityID uint, fundingType programs.FundingType) *programs.Program { - program := &programs.Program{ - Name: name, - IsActive: true, - FundingType: fundingType, - } - suite.env.DB.Create(program) - - association := &programs.FacilitiesPrograms{ - FacilityID: facilityID, - ProgramID: program.ID, - } - suite.env.DB.Create(association) - - return program -} - -func (suite *ProgramOutcomesReportTestSuite) addProgramType(programID uint, progType programs.ProgType) { - pt := &programs.ProgramType{ - ProgramID: programID, - ProgramType: progType, - } - suite.env.DB.Create(pt) -} - -func (suite *ProgramOutcomesReportTestSuite) createClass(name string, programID, facilityID uint, status programs.ClassStatus) *programs.ProgramClassCohort { - class := &programs.ProgramClassCohort{ - ProgramID: programID, - FacilityID: facilityID, - Status: status, - } - suite.env.DB.Create(class) - return class -} - -func (suite *ProgramOutcomesReportTestSuite) createStudent(firstName, lastName string, facilityID uint) *models.User { - timestamp := fmt.Sprintf("%d", time.Now().UnixNano()) - student := &models.User{ - NameFirst: firstName, - NameLast: lastName, - Username: firstName + lastName + timestamp, - Email: firstName + lastName + timestamp + "@test.com", - Role: models.Student, - FacilityID: facilityID, - } - suite.env.DB.Create(student) - return student -} - -func (suite *ProgramOutcomesReportTestSuite) createEnrollment(classID, userID uint, enrolledAt time.Time, status programs.ProgramEnrollmentStatus) *programs.ProgramClassEnrollment { - enrollment := &programs.ProgramClassEnrollment{ - CohortID: classID, - UserID: userID, - EnrolledAt: &enrolledAt, - EnrollmentStatus: status, - } - suite.env.DB.Create(enrollment) - return enrollment -} - -func (suite *ProgramOutcomesReportTestSuite) createEvent(classID uint, eventDate time.Time) *programs.ProgramClassEvent { - rrule := fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=DAILY;COUNT=1", eventDate.Format("20060102T090000Z")) - roomID, err := suite.env.getOrCreateTestRoom(classID) - if err != nil { - suite.T().Fatalf("failed to create test room: %v", err) - } - instructorID, err := suite.env.getOrCreateInstructorForClass(classID) - if err != nil { - suite.T().Fatalf("failed to get instructor: %v", err) - } - event := &programs.ProgramClassEvent{ - CohortID: classID, - Duration: "2h", - RecurrenceRule: rrule, - RoomID: &roomID, - InstructorID: &instructorID, - } - suite.env.DB.Create(event) - return event -} - -func (suite *ProgramOutcomesReportTestSuite) createAttendance(eventID, userID uint, status programs.Attendance) { - attendance := &programs.ProgramClassEventAttendance{ - EventID: eventID, - UserID: userID, - AttendanceStatus: status, - } - suite.env.DB.Create(attendance) -} - -func stringPtr(s string) *string { - return &s -} - -func TestProgramOutcomesReportTestSuite(t *testing.T) { - suite.Run(t, new(ProgramOutcomesReportTestSuite)) -} diff --git a/backend/tests/integration/programs_core_reads_test.go b/backend/tests/integration/programs_core_reads_test.go index 8ccd19869..0383cfd29 100644 --- a/backend/tests/integration/programs_core_reads_test.go +++ b/backend/tests/integration/programs_core_reads_test.go @@ -8,38 +8,42 @@ import ( "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" "github.com/stretchr/testify/require" ) -// These tests pin the core routes that read or write Programs' tables -// (EN-200): the facility list's counts and can_delete, the facility delete -// check and delete, a resident's transfer, and the account history's class -// change times. They passed unchanged when core started asking Programs for -// those numbers and the tables moved into the programs schema. +// These tests pin the core routes that use Programs' numbers (EN-200): the +// facility list's counts and can_delete, the facility delete check and +// delete, a resident's transfer, and the account history's class change +// times. Since EN-207 core asks the Programs service, here a stand-in that +// answers with what the original seed's rows gave, and the golden files are +// unchanged. // // Regenerate the golden files after an intended change with: // // go test ./tests/integration -run 'TestProgramsCoreReads' -update -// The seeded account history row is an hour newer than the -// program_classes_history row it points at. +// The seeded account history row is an hour newer than the class change it +// points at. var ( classHistoryAt = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) accountHistoryAt = classHistoryAt.Add(time.Hour) ) +// classHistoryID is the Programs class change the account history row points +// at. +const classHistoryID = 41 + type programsCoreWorld struct { north, south, classHall, programWing, roomAnnex, empty *models.Facility resident, deptAdmin *models.User - northClass, weldingClass *programs.ProgramClassCohort } // seedProgramsCore adds facilities that each hold one kind of Programs data, // so each golden shows which kinds block deleting a facility: -// - North has the resident, an active program and class, and a room; -// - South offers the same program, and is where the resident transfers; +// - North has the resident, two active programs and classes, and a room; +// - South offers one of the programs, and is where the resident transfers; // - Class Hall has only a class; // - Program Wing only offers a program; // - Room Annex has only a room; @@ -64,41 +68,25 @@ func seedProgramsCore(t *testing.T, env *TestEnv) programsCoreWorld { w.deptAdmin, err = env.CreateTestUser("deptadmin", models.DepartmentAdmin, w.north.ID, "") require.NoError(t, err) - reading, err := env.CreateTestProgram("Reading", programs.Other, nil, nil, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(reading.ID, []uint{w.north.ID, w.south.ID, w.programWing.ID})) - w.northClass, err = env.CreateTestClass(reading, w.north, programs.Active, nil) - require.NoError(t, err) - _, err = env.CreateTestClass(reading, w.classHall, programs.Active, nil) - require.NoError(t, err) - _, err = env.CreateTestEnrollment(w.northClass.ID, w.resident.ID, programs.Enrolled) - require.NoError(t, err) - // Welding is offered only at North, so it shows as a transfer conflict. - welding, err := env.CreateTestProgram("Welding", programs.Other, nil, nil, true, nil) - require.NoError(t, err) - require.NoError(t, env.SetFacilitiesToProgram(welding.ID, []uint{w.north.ID})) - w.weldingClass, err = env.CreateTestClass(welding, w.north, programs.Active, nil) - require.NoError(t, err) - _, err = env.CreateTestEnrollment(w.weldingClass.ID, w.resident.ID, programs.Enrolled) - require.NoError(t, err) - for _, f := range []*models.Facility{w.north, w.roomAnnex} { - require.NoError(t, env.DB.Create(&programs.Room{FacilityID: f.ID, Name: f.Name + " Room"}).Error) - } - // Test classes start now; a fixed start keeps the goldens stable. - start := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC) - require.NoError(t, env.DB.Model(&programs.ProgramClassCohort{}). - Where("id IN ?", []uint{w.northClass.ID, w.weldingClass.ID}).Update("start_dt", start).Error) - - // An enrollment change logged the way the program_classes_history trigger - // logs one, with an account history row pointing at it. - historyID := env.InsertReturningID(t, - `INSERT INTO program_classes_history (parent_ref_id, table_name, before_update, after_update, created_at) - VALUES (?, 'program_class_cohorts', '{"status":"Scheduled"}', '{"status":"Active"}', ?)`, - w.northClass.ID, classHistoryAt) + // What Programs counts at each facility, and the class the resident would + // leave behind: Welding is offered only at North. + env.Programs.set(func(f *fakePrograms) { + f.facilityPrograms = map[uint]programsapi.FacilityPrograms{ + w.north.ID: {ActivePrograms: 2, ActiveClasses: 2, Classes: 2, Programs: 2, Rooms: 1}, + w.south.ID: {ActivePrograms: 1, Programs: 1}, + w.classHall.ID: {ActiveClasses: 1, Classes: 1}, + w.programWing.ID: {ActivePrograms: 1, Programs: 1}, + w.roomAnnex.ID: {Rooms: 1}, + } + f.transferConflicts = []programsapi.ResidentTransferProgramConflicts{{ProgramName: "Welding", ClassName: "Welding"}} + f.classHistoryAt = map[uint]time.Time{classHistoryID: classHistoryAt} + }) + + // An account history row pointing at a class change in Programs. require.NoError(t, env.DB.Exec( `INSERT INTO user_account_history (user_id, admin_id, action, program_classes_history_id, facility_id, created_at) VALUES (?, ?, 'progclass_history', ?, ?, ?)`, - w.resident.ID, w.deptAdmin.ID, historyID, w.north.ID, accountHistoryAt).Error) + w.resident.ID, w.deptAdmin.ID, classHistoryID, w.north.ID, accountHistoryAt).Error) return w } @@ -161,17 +149,16 @@ func TestProgramsCoreReads(t *testing.T) { // The transfer route reads the user on a second connection while its // transaction is open, which SQLite's single connection can't serve, so - // the transfer itself runs at the database layer. + // the transfer itself runs at the database layer. The route asks Programs + // first; TestProgramsChangesFirst covers a failure there. t.Run("transfer", func(t *testing.T) { args := &models.QueryContext{Ctx: t.Context(), UserID: w.deptAdmin.ID} - tx, err := env.DB.TransferResident(args, int(w.resident.ID), int(w.north.ID), int(w.south.ID)) + tx, err := env.DB.TransferResident(args, int(w.resident.ID), int(w.south.ID)) require.NoError(t, err) require.NoError(t, tx.Commit().Error) }) run(t, []routeCase{ - {"enrollments_after_transfer", http.MethodGet, fmt.Sprintf("/api/program-classes/%d/enrollments?status=all", w.northClass.ID), http.StatusOK}, - {"welding_enrollments_after_transfer", http.MethodGet, fmt.Sprintf("/api/program-classes/%d/enrollments?status=all", w.weldingClass.ID), http.StatusOK}, {"facilities_after", http.MethodGet, "/api/facilities", http.StatusOK}, }) } diff --git a/backend/tests/integration/programs_core_routes_test.go b/backend/tests/integration/programs_core_routes_test.go new file mode 100644 index 000000000..1543c80d1 --- /dev/null +++ b/backend/tests/integration/programs_core_routes_test.go @@ -0,0 +1,293 @@ +package integration + +import ( + "encoding/json" + "fmt" + "net/http" + "sort" + "testing" + "time" + + "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" + "github.com/UnlockedLabs/unlocked-core/backend/src/models" + + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" + "github.com/stretchr/testify/require" +) + +// These tests pin the rest of core's routes that ask Programs (EN-194): the +// dashboard and department metrics, the users list's class filters, +// deactivating a resident, and the account history's attendance rows. The +// stand-in Programs answers with what the original seed's rows gave, so the +// golden files are the ones the routes answered before Programs moved out. +// +// Regenerate the golden files after an intended change with: +// +// go test ./tests/integration -run 'TestProgramsCoreRoutes' -update + +type programsRoutesWorld struct { + north, south *models.Facility + // jdoe, leaver and bulkLeaver are in class 1; newbie could join it. + // mmajor completed it. + resident, mmajor, newbie, leaver, bulkLeaver, southResident *models.User + facAdmin, southAdmin, deptAdmin, instructor *models.User +} + +func (w programsRoutesWorld) claims(u *models.User) *handlers.Claims { + return &handlers.Claims{UserID: u.ID, Username: u.Username, Role: u.Role, FacilityID: u.FacilityID, TimeZone: "America/Chicago"} +} + +// seedProgramsRoutes adds North and South, their users, and Programs' +// answers about them. +func seedProgramsRoutes(t *testing.T, env *TestEnv) programsRoutesWorld { + t.Helper() + var w programsRoutesWorld + var err error + w.north, err = env.CreateTestFacility("North") + require.NoError(t, err) + w.south, err = env.CreateTestFacility("South") + require.NoError(t, err) + for _, u := range []struct { + user **models.User + username string + role models.UserRole + facility uint + docID string + }{ + {&w.resident, "jdoe", models.Student, w.north.ID, "1001"}, + {&w.mmajor, "mmajor", models.Student, w.north.ID, "1002"}, + {&w.newbie, "newbie", models.Student, w.north.ID, "1003"}, + {&w.leaver, "leaver", models.Student, w.north.ID, "1004"}, + {&w.bulkLeaver, "bulkleaver", models.Student, w.north.ID, "1005"}, + {&w.southResident, "southres", models.Student, w.south.ID, "2001"}, + {&w.facAdmin, "facadmin", models.FacilityAdmin, w.north.ID, ""}, + {&w.southAdmin, "southadmin", models.FacilityAdmin, w.south.ID, ""}, + {&w.deptAdmin, "deptadmin", models.DepartmentAdmin, w.north.ID, ""}, + {&w.instructor, "instructornorth", models.FacilityAdmin, w.north.ID, ""}, + } { + *u.user, err = env.CreateTestUser(u.username, u.role, u.facility, u.docID) + require.NoError(t, err) + } + for _, stmt := range []struct { + sql string + args []any + }{ + {`UPDATE users SET name_first = 'John', name_last = 'Doe' WHERE id = ?`, []any{w.resident.ID}}, + {`UPDATE users SET name_first = 'Mary', name_last = 'Major' WHERE id = ?`, []any{w.mmajor.ID}}, + {`UPDATE users SET name_first = 'Ivy', name_last = 'Instructor' WHERE id = ?`, []any{w.instructor.ID}}, + // The account history row Programs writes when it records attendance. + {`INSERT INTO user_account_history (user_id, admin_id, action, attendance_status, class_name, session_date, created_at) + VALUES (?, ?, 'attendance_recorded', 'partial', 'Reading 101', ?, ?)`, + []any{w.mmajor.ID, w.facAdmin.ID, time.Date(2026, 1, 26, 0, 0, 0, 0, time.UTC), time.Date(2026, 1, 26, 15, 0, 0, 0, time.UTC)}}, + } { + require.NoError(t, env.DB.Exec(stmt.sql, stmt.args...).Error, stmt.sql) + } + + north, south := w.north.ID, w.south.ID + env.Programs.set(func(f *fakePrograms) { + f.enrolled = map[uint][]uint{1: {w.resident.ID, w.leaver.ID, w.bulkLeaver.ID}} + f.eligible = map[uint][]uint{1: {w.newbie.ID}} + f.engagement = programsapi.ProgramEngagementOverview{ + ActiveResidents: 5, PreviouslyEngagedResidents: 1, TotalResidents: 6, + TopPrograms: []programsapi.ProgramCompletionRank{{ProgramName: "Adult Literacy", Enrolled: 5, Completed: 1, CompletionRate: 20}}, + } + f.secondEnrollment = []programsapi.SecondProgramEnrollmentRow{{FacilityName: "North", ProgramType: "Educational", CompletedFirst: 1}} + f.completionMatrix = []programsapi.ProgramCompletionMatrixCell{ + {ProgramName: "Adult Literacy", FacilityID: south, FacilityName: "South", Enrolled: 1, Insufficient: true}, + {ProgramName: "Welding", FacilityID: north, FacilityName: "North", Enrolled: 2, Insufficient: true}, + {ProgramName: "Adult Literacy", FacilityID: north, FacilityName: "North", Enrolled: 4, Completed: 1, CompletionRate: 25, DeltaFromFacilityAverage: 8.333333333333336}, + } + f.loadDistribution = programsapi.ProgramLoadDistribution{ + Statewide: []programsapi.ProgramLoadBucket{{Bucket: "0", Count: 2}, {Bucket: "1", Count: 3}, {Bucket: "2", Count: 1}, {Bucket: "3"}, {Bucket: "4+"}}, + ByFacility: []programsapi.ProgramLoadFacilityRow{ + {FacilityID: north, FacilityName: "North", Zero: 2, One: 2, Two: 1, Total: 5}, + {FacilityID: south, FacilityName: "South", One: 1, Total: 1}, + }, + } + f.enrollmentByType = []programsapi.ProgramTypeEnrollment{ + {ProgramType: "Educational", Enrolled: 5, Completed: 1, Rate: 20}, + {ProgramType: "Vocational", Enrolled: 2}, + } + f.classMetrics = programsapi.ClassDashboardMetrics{ActiveClasses: 1, ScheduledClasses: 2, TotalEnrollments: 3, TotalSeats: 10} + f.facilityHealth = []programsapi.FacilityHealthSummary{ + {FacilityID: 1, FacilityName: "Default"}, + {FacilityID: south, FacilityName: "South", Programs: 2, ActiveClasses: 1, Enrollment: 1}, + {FacilityID: north, FacilityName: "North", Programs: 2, ActiveClasses: 1, Enrollment: 3}, + } + }) + return w +} + +func TestProgramsCoreRoutes(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + w := seedProgramsRoutes(t, env) + client := noRedirects(env) + + facAdmin, deptAdmin := w.claims(w.facAdmin), w.claims(w.deptAdmin) + user := func(format string, u *models.User) string { return fmt.Sprintf(format, u.ID) } + + // The cases run in order against one database, so later reads show the + // earlier writes. sorted marks routes whose row order isn't fixed: their + // arrays are compared in sorted order. + tests := []struct { + name string + claims *handlers.Claims + method string + path string + body any + status int + sorted bool + }{ + {"users_only_unenrolled", facAdmin, http.MethodGet, "/api/users?include=only_unenrolled&cohort_id=1", nil, http.StatusOK, false}, + {"users_only_enrolled", facAdmin, http.MethodGet, "/api/users?include=only_enrolled&cohort_id=1", nil, http.StatusOK, false}, + {"users_include_bad_cohort", facAdmin, http.MethodGet, "/api/users?include=only_enrolled&cohort_id=abc", nil, http.StatusBadRequest, false}, + {"metrics_engagement", deptAdmin, http.MethodGet, "/api/department-metrics/programs/engagement-overview?facility=all&all_time=true", nil, http.StatusOK, true}, + {"metrics_second_enrollment", deptAdmin, http.MethodGet, "/api/department-metrics/programs/second-enrollment?facility=all", nil, http.StatusOK, true}, + {"metrics_completion_matrix", deptAdmin, http.MethodGet, "/api/department-metrics/programs/completion-matrix?facility=all", nil, http.StatusOK, true}, + {"metrics_load_distribution", deptAdmin, http.MethodGet, "/api/department-metrics/programs/load-distribution?facility=all", nil, http.StatusOK, true}, + {"metrics_enrollment_by_type", deptAdmin, http.MethodGet, "/api/department-metrics/programs/enrollment-by-type?facility=all", nil, http.StatusOK, false}, + {"dashboard_class_metrics", facAdmin, http.MethodGet, "/api/dashboard/class-metrics", nil, http.StatusOK, false}, + {"dashboard_facility_health", deptAdmin, http.MethodGet, "/api/dashboard/facility-health?facility=all", nil, http.StatusOK, true}, + {"user_deactivate", facAdmin, http.MethodPost, user("/api/users/%d/deactivate", w.leaver), nil, http.StatusOK, false}, + {"users_bulk_deactivate", facAdmin, http.MethodPost, "/api/users/bulk/deactivate", map[string]any{"user_ids": []uint{w.bulkLeaver.ID}}, http.StatusOK, false}, + {"account_history_after_deactivate", facAdmin, http.MethodGet, user("/api/users/%d/account-history", w.leaver), nil, http.StatusOK, false}, + {"account_history_attendance", facAdmin, http.MethodGet, user("/api/users/%d/account-history?categories=attendance", w.mmajor), nil, http.StatusOK, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := NewRequest[any](client, t, tt.method, tt.path, tt.body). + WithTestClaims(tt.claims).AsRaw().Do(). + ExpectStatus(tt.status) + body := resp.rawBody + if tt.sorted { + body = sortArrays(t, body) + } + expectGolden(t, "programs_core_routes", tt.name, body) + }) + } + + t.Run("deactivating withdraws the enrollments in Programs", func(t *testing.T) { + _, withdrawn := env.Programs.changes() + require.Equal(t, []uint{w.leaver.ID, w.bulkLeaver.ID}, withdrawn) + }) +} + +// TestProgramsCoreRouteAccess pins who may call core's Programs dashboard and +// metrics routes: any admin, with or without program_management. +func TestProgramsCoreRouteAccess(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + w := seedProgramsRoutes(t, env) + client := noRedirects(env) + + resident, facAdmin := w.claims(w.resident), w.claims(w.facAdmin) + for _, path := range []string{ + "/api/department-metrics/programs/engagement-overview", + "/api/department-metrics/programs/second-enrollment", + "/api/department-metrics/programs/completion-matrix", + "/api/department-metrics/programs/load-distribution", + "/api/department-metrics/programs/enrollment-by-type", + "/api/dashboard/class-metrics", + "/api/dashboard/facility-health", + } { + t.Run(path, func(t *testing.T) { + NewRequest[any](client, t, http.MethodGet, path, nil). + WithTestClaims(resident).Do(). + ExpectStatus(http.StatusUnauthorized).ExpectMessage("Unauthorized - not admin") + for _, c := range []*handlers.Claims{facAdmin, withFeatures(facAdmin, models.ProgramAccess)} { + resp := NewRequest[any](client, t, http.MethodGet, path, nil). + WithTestClaims(c).AsRaw().Do() + require.NotEqual(t, http.StatusUnauthorized, resp.resp.StatusCode, resp.rawBody) + } + }) + } +} + +// TestProgramsChangesFirst pins that a transfer or a deactivation asks +// Programs first, and changes nothing in core when Programs fails. +func TestProgramsChangesFirst(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + w := seedProgramsRoutes(t, env) + env.Programs.set(func(f *fakePrograms) { f.failChanges = true }) + facAdmin, deptAdmin := w.claims(w.facAdmin), w.claims(w.deptAdmin) + + t.Run("transfer", func(t *testing.T) { + NewRequest[any](env.Client, t, http.MethodPatch, "/api/users/resident-transfer", map[string]any{ + "user_id": w.resident.ID, "curr_facility_id": w.north.ID, "trans_facility_id": w.south.ID, + }).WithTestClaims(deptAdmin).Do(). + ExpectStatus(http.StatusInternalServerError).ExpectMessage("error updating program_class_enrollments") + resident, err := env.DB.GetUserByID(w.resident.ID) + require.NoError(t, err) + require.Equal(t, w.north.ID, resident.FacilityID) + }) + t.Run("deactivate", func(t *testing.T) { + NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/users/%d/deactivate", w.leaver.ID), nil). + WithTestClaims(facAdmin).Do(). + ExpectStatus(http.StatusInternalServerError).ExpectMessage("error updating program_class_enrollments") + leaver, err := env.DB.GetUserByID(w.leaver.ID) + require.NoError(t, err) + require.Nil(t, leaver.DeactivatedAt) + }) + t.Run("bulk deactivate", func(t *testing.T) { + resp := NewRequest[struct { + SuccessCount int `json:"success_count"` + FailedCount int `json:"failed_count"` + }](env.Client, t, http.MethodPost, "/api/users/bulk/deactivate", map[string]any{"user_ids": []uint{w.bulkLeaver.ID}}). + WithTestClaims(facAdmin).Do(). + ExpectStatus(http.StatusOK) + require.Equal(t, 0, resp.GetData().SuccessCount) + require.Equal(t, 1, resp.GetData().FailedCount) + bulkLeaver, err := env.DB.GetUserByID(w.bulkLeaver.ID) + require.NoError(t, err) + require.Nil(t, bulkLeaver.DeactivatedAt) + }) + ended, withdrawn := env.Programs.changes() + require.Empty(t, ended) + require.Empty(t, withdrawn) +} + +// sortArrays orders every JSON array by its elements' JSON, so a route whose +// row order isn't fixed compares the same on every run. +func sortArrays(t *testing.T, body string) string { + t.Helper() + var v any + if err := json.Unmarshal([]byte(body), &v); err != nil { + return body + } + var walk func(any) any + walk = func(v any) any { + switch v := v.(type) { + case map[string]any: + for k, x := range v { + v[k] = walk(x) + } + case []any: + keys := make([]string, len(v)) + for i, x := range v { + v[i] = walk(x) + b, err := json.Marshal(v[i]) + require.NoError(t, err) + keys[i] = string(b) + } + sort.Sort(byKey{keys, v}) + } + return v + } + out, err := json.Marshal(walk(v)) + require.NoError(t, err) + return string(out) +} + +type byKey struct { + keys []string + vals []any +} + +func (b byKey) Len() int { return len(b.keys) } +func (b byKey) Less(i, j int) bool { return b.keys[i] < b.keys[j] } +func (b byKey) Swap(i, j int) { + b.keys[i], b.keys[j] = b.keys[j], b.keys[i] + b.vals[i], b.vals[j] = b.vals[j], b.vals[i] +} diff --git a/backend/tests/integration/programs_fake.go b/backend/tests/integration/programs_fake.go new file mode 100644 index 000000000..156194f22 --- /dev/null +++ b/backend/tests/integration/programs_fake.go @@ -0,0 +1,193 @@ +package integration + +import ( + "context" + "errors" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/UnlockedLabs/unlocked-contracts/coreapi" + programsapi "github.com/UnlockedLabs/unlocked-contracts/programs" +) + +// fakePrograms stands in for the Programs service (EN-207). Core asks it over +// HTTP, through the same client and handler as in production, and it answers +// with what the test sets. Unset answers are empty, as a site with no Programs +// data answers. +type fakePrograms struct { + mu sync.Mutex + // enrolled and eligible are each class's users. + enrolled, eligible map[uint][]uint + transferConflicts []programsapi.ResidentTransferProgramConflicts + facilityPrograms map[uint]programsapi.FacilityPrograms + classHistoryAt map[uint]time.Time + engagement programsapi.ProgramEngagementOverview + secondEnrollment []programsapi.SecondProgramEnrollmentRow + completionMatrix []programsapi.ProgramCompletionMatrixCell + loadDistribution programsapi.ProgramLoadDistribution + enrollmentByType []programsapi.ProgramTypeEnrollment + classMetrics programsapi.ClassDashboardMetrics + facilityHealth []programsapi.FacilityHealthSummary + // failChanges makes ending and withdrawing enrollments fail. + failChanges bool + // ended and withdrawn are the enrollment changes core asked for, in order. + ended []endedForTransfer + withdrawn []uint +} + +// endedForTransfer is one EndEnrollmentsForTransfer call. +type endedForTransfer struct { + userID, fromFacilityID uint +} + +var _ programsapi.Programs = (*fakePrograms)(nil) + +// fakeProgramsToken is the site's secret in core's tests. +const fakeProgramsToken = "test-programs-token" + +// startFakePrograms serves a fakePrograms and points PROGRAMS_URL and +// PROGRAMS_TOKEN at it, for the test server core builds next. +func startFakePrograms(t *testing.T) *fakePrograms { + t.Helper() + fake := &fakePrograms{} + ts := httptest.NewServer(programsapi.NewHandler(fake, fakeProgramsToken)) + t.Cleanup(ts.Close) + t.Setenv("PROGRAMS_URL", ts.URL) + t.Setenv("PROGRAMS_TOKEN", fakeProgramsToken) + return fake +} + +// set changes the fake's answers. +func (f *fakePrograms) set(change func(f *fakePrograms)) { + f.mu.Lock() + defer f.mu.Unlock() + change(f) +} + +// changes is the enrollment changes core asked for. +func (f *fakePrograms) changes() ([]endedForTransfer, []uint) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]endedForTransfer{}, f.ended...), append([]uint{}, f.withdrawn...) +} + +func (f *fakePrograms) EnrolledUserIDs(_ context.Context, classID uint) ([]uint, error) { + f.mu.Lock() + defer f.mu.Unlock() + return orEmpty(f.enrolled[classID]), nil +} + +func (f *fakePrograms) EligibleUserIDs(_ context.Context, classID uint) ([]uint, error) { + f.mu.Lock() + defer f.mu.Unlock() + return orEmpty(f.eligible[classID]), nil +} + +func (f *fakePrograms) TransferConflicts(context.Context, uint, uint) ([]programsapi.ResidentTransferProgramConflicts, error) { + f.mu.Lock() + defer f.mu.Unlock() + return orEmpty(f.transferConflicts), nil +} + +func (f *fakePrograms) EndEnrollmentsForTransfer(_ context.Context, userID, fromFacilityID uint) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failChanges { + return errors.New("programs is down") + } + f.ended = append(f.ended, endedForTransfer{userID, fromFacilityID}) + return nil +} + +func (f *fakePrograms) WithdrawEnrollments(_ context.Context, userID uint) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failChanges { + return errors.New("programs is down") + } + f.withdrawn = append(f.withdrawn, userID) + return nil +} + +func (f *fakePrograms) ResidentPrograms(context.Context, uint) ([]programsapi.ResidentProgramClassInfo, error) { + return []programsapi.ResidentProgramClassInfo{}, nil +} + +func (f *fakePrograms) FacilityPrograms(_ context.Context, facilityIDs []uint) (map[uint]programsapi.FacilityPrograms, error) { + f.mu.Lock() + defer f.mu.Unlock() + progs := map[uint]programsapi.FacilityPrograms{} + for _, id := range facilityIDs { + if p, ok := f.facilityPrograms[id]; ok { + progs[id] = p + } + } + return progs, nil +} + +func (f *fakePrograms) ClassHistoryCreatedAt(_ context.Context, ids []uint) (map[uint]time.Time, error) { + f.mu.Lock() + defer f.mu.Unlock() + times := map[uint]time.Time{} + for _, id := range ids { + if at, ok := f.classHistoryAt[id]; ok { + times[id] = at + } + } + return times, nil +} + +func (f *fakePrograms) EngagementOverview(context.Context, *time.Time, *time.Time, *uint) (programsapi.ProgramEngagementOverview, error) { + f.mu.Lock() + defer f.mu.Unlock() + overview := f.engagement + overview.TopPrograms = orEmpty(overview.TopPrograms) + return overview, nil +} + +func (f *fakePrograms) SecondProgramEnrollment(context.Context, *uint) ([]programsapi.SecondProgramEnrollmentRow, error) { + f.mu.Lock() + defer f.mu.Unlock() + return orEmpty(f.secondEnrollment), nil +} + +func (f *fakePrograms) CompletionMatrix(context.Context, *uint) ([]programsapi.ProgramCompletionMatrixCell, error) { + f.mu.Lock() + defer f.mu.Unlock() + return orEmpty(f.completionMatrix), nil +} + +func (f *fakePrograms) LoadDistribution(context.Context, *uint) (programsapi.ProgramLoadDistribution, error) { + f.mu.Lock() + defer f.mu.Unlock() + load := f.loadDistribution + load.Statewide = orEmpty(load.Statewide) + load.ByFacility = orEmpty(load.ByFacility) + return load, nil +} + +func (f *fakePrograms) EnrollmentByProgramType(context.Context, *uint) ([]programsapi.ProgramTypeEnrollment, error) { + f.mu.Lock() + defer f.mu.Unlock() + return orEmpty(f.enrollmentByType), nil +} + +func (f *fakePrograms) ClassMetrics(context.Context, *uint) (programsapi.ClassDashboardMetrics, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.classMetrics, nil +} + +func (f *fakePrograms) FacilityHealth(context.Context, *uint, int, string) ([]programsapi.FacilityHealthSummary, error) { + f.mu.Lock() + defer f.mu.Unlock() + return orEmpty(f.facilityHealth), nil +} + +func (f *fakePrograms) LiveUsers(context.Context, uint) ([]coreapi.ProviderUser, bool, error) { + return nil, false, nil +} + +func (f *fakePrograms) MappingsChanged(uint, uint) {} diff --git a/backend/tests/integration/programs_handler_test.go b/backend/tests/integration/programs_handler_test.go deleted file mode 100644 index e0f7ad45d..000000000 --- a/backend/tests/integration/programs_handler_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -type ProgramsHandlerTestSuite struct { - suite.Suite - env *TestEnv -} - -func (suite *ProgramsHandlerTestSuite) SetupSuite() { - suite.env = SetupTestEnv(suite.T()) -} - -func (suite *ProgramsHandlerTestSuite) TearDownSuite() { - suite.env.CleanupTestEnv() -} - -func (suite *ProgramsHandlerTestSuite) SetupTest() { - // Clean database before each test - suite.cleanDatabase() -} - -func (suite *ProgramsHandlerTestSuite) cleanDatabase() { - // Clean up test data in reverse dependency order - suite.env.DB.Exec("DELETE FROM program_class_event_attendance") - suite.env.DB.Exec("DELETE FROM program_class_events") - suite.env.DB.Exec("DELETE FROM program_class_enrollments") - suite.env.DB.Exec("DELETE FROM program_class_cohorts") - suite.env.DB.Exec("DELETE FROM program_class_credit_types") - suite.env.DB.Exec("DELETE FROM program_classes") - suite.env.DB.Exec("DELETE FROM facilities_programs") - suite.env.DB.Exec("DELETE FROM programs") - suite.env.DB.Exec("DELETE FROM facilities") - suite.env.DB.Exec("DELETE FROM users WHERE role != 'system_admin'") -} - -func (suite *ProgramsHandlerTestSuite) setupTestData() (*models.Facility, *models.User, *models.User) { - // Create test facility - facility := &models.Facility{ - Name: "Test Facility", - } - suite.env.DB.Create(facility) - - // Create system admin user - timestamp := fmt.Sprintf("%d", time.Now().UnixNano()) - systemAdmin := &models.User{ - NameFirst: "System", - NameLast: "Admin", - Username: "sysadmin" + timestamp, - Email: "sysadmin" + timestamp + "@test.com", - Role: models.SystemAdmin, - FacilityID: facility.ID, - } - suite.env.DB.Create(systemAdmin) - - // Create facility admin user - facilityAdmin := &models.User{ - NameFirst: "Facility", - NameLast: "Admin", - Username: "facadmin" + timestamp, - Email: "facadmin" + timestamp + "@test.com", - Role: models.FacilityAdmin, - FacilityID: facility.ID, - } - suite.env.DB.Create(facilityAdmin) - - // Create test program and data - program := &programs.Program{ - Name: "Test Program", - IsActive: true, - } - suite.env.DB.Create(program) - - // Associate program with facility - association := &programs.FacilitiesPrograms{ - FacilityID: facility.ID, - ProgramID: program.ID, - } - suite.env.DB.Create(association) - - // Create active class - class := &programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Status: programs.Active, - } - suite.env.DB.Create(class) - - // Create student and enrollment - student := &models.User{ - NameFirst: "Test", - NameLast: "Student", - Username: "student" + timestamp, - Email: "student" + timestamp + "@test.com", - Role: models.Student, - FacilityID: facility.ID, - } - suite.env.DB.Create(student) - - now := time.Now() - enrollment := &programs.ProgramClassEnrollment{ - CohortID: class.ID, - UserID: student.ID, - EnrollmentStatus: programs.Enrolled, - EnrolledAt: &now, - } - suite.env.DB.Create(enrollment) - - return facility, systemAdmin, facilityAdmin -} - -func (suite *ProgramsHandlerTestSuite) TestProgramsStatsHandler_SystemAdmin() { - facility, systemAdmin, _ := suite.setupTestData() - - // Make request as system admin - resp := NewRequest[programs.ProgramsFacilitiesStats](suite.env.Client, suite.T(), http.MethodGet, "/api/programs/stats?days=all", nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: systemAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - ExpectMessage("Data fetched successfully") - - result := resp.GetData() - - assert.NotNil(suite.T(), result.TotalPrograms) - assert.NotNil(suite.T(), result.TotalEnrollments) - assert.NotNil(suite.T(), result.AvgActiveProgramsPerFacility) - assert.NotNil(suite.T(), result.AttendanceRate) - assert.NotNil(suite.T(), result.CompletionRate) - - // System admin should see aggregated data - assert.Equal(suite.T(), int64(1), *result.TotalPrograms) - assert.Equal(suite.T(), int64(1), *result.TotalEnrollments) - assert.Equal(suite.T(), float64(1), *result.AvgActiveProgramsPerFacility) -} - -func (suite *ProgramsHandlerTestSuite) TestProgramsStatsHandler_FacilityAdmin() { - facility, _, facilityAdmin := suite.setupTestData() - - // Make request as facility admin - resp := NewRequest[programs.ProgramsFacilitiesStats](suite.env.Client, suite.T(), http.MethodGet, "/api/programs/stats?days=all", nil). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - ExpectMessage("Data fetched successfully") - - result := resp.GetData() - - assert.NotNil(suite.T(), result.TotalPrograms) - assert.NotNil(suite.T(), result.TotalEnrollments) - assert.NotNil(suite.T(), result.AvgActiveProgramsPerFacility) - - // Facility admin should see facility-specific data - assert.Equal(suite.T(), int64(1), *result.TotalPrograms) - assert.Equal(suite.T(), int64(1), *result.TotalEnrollments) - assert.Equal(suite.T(), float64(1), *result.AvgActiveProgramsPerFacility) -} - -func (suite *ProgramsHandlerTestSuite) TestProgramsStatsHandler_TimeFilterParsing() { - facility, systemAdmin, _ := suite.setupTestData() - - // Test various time filter values - testCases := []struct { - daysParam string - expectedCode int - description string - }{ - {"all", http.StatusOK, "days=all should work"}, - {"30", http.StatusOK, "days=30 should work"}, - {"0", http.StatusOK, "days=0 should work"}, - {"-1", http.StatusOK, "days=-1 should work"}, - {"invalid", http.StatusOK, "invalid days should default to -1"}, - {"", http.StatusOK, "missing days should default to -1"}, - } - - for _, tc := range testCases { - suite.T().Run(tc.description, func(t *testing.T) { - url := "/api/programs/stats" - if tc.daysParam != "" { - url += "?days=" + tc.daysParam - } - - resp := NewRequest[programs.ProgramsFacilitiesStats](suite.env.Client, t, http.MethodGet, url, nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: systemAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(tc.expectedCode) - - if tc.expectedCode == http.StatusOK { - resp.ExpectMessage("Data fetched successfully") - } - }) - } -} - -// Disabled: Authentication middleware has issues in test environment -// func (suite *ProgramsHandlerTestSuite) TestProgramsStatsHandler_Unauthorized() { -// // Make request without authentication -// NewRequest[programs.ProgramsFacilitiesStats](suite.env.Client, suite.T(), http.MethodGet, "/api/programs/stats?days=all", nil). -// Do(). -// ExpectStatus(http.StatusUnauthorized) -// } - -func (suite *ProgramsHandlerTestSuite) TestProgramsStatsHandler_StudentRole() { - timestamp := fmt.Sprintf("%d", time.Now().UnixNano()) - facility := &models.Facility{Name: "Test Facility"} - suite.env.DB.Create(facility) - - // Create student user - student := &models.User{ - NameFirst: "Test", - NameLast: "Student", - Username: "teststudent" + timestamp, - Email: "student" + timestamp + "@test.com", - Role: models.Student, - FacilityID: facility.ID, - } - suite.env.DB.Create(student) - - // Students should not have access to program stats - resp := NewRequest[programs.ProgramsFacilitiesStats](suite.env.Client, suite.T(), http.MethodGet, "/api/programs/stats?days=all", nil). - WithTestClaims(&handlers.Claims{Role: models.Student, UserID: student.ID, FacilityID: facility.ID}). - Do() - - // Expect 403 Forbidden or similar authorization error - assert.True(suite.T(), resp.resp.StatusCode == http.StatusForbidden || resp.resp.StatusCode == http.StatusUnauthorized) -} - -func (suite *ProgramsHandlerTestSuite) TestProgramsStatsHandler_EmptyDatabase() { - facility := &models.Facility{Name: "Empty Facility"} - suite.env.DB.Create(facility) - - systemAdmin := &models.User{ - NameFirst: "System", - NameLast: "Admin", - Username: "emptyadmin" + fmt.Sprintf("%d", time.Now().Unix()), - Email: "empty@test.com", - Role: models.SystemAdmin, - FacilityID: facility.ID, - } - suite.env.DB.Create(systemAdmin) - - // Test with empty database - resp := NewRequest[programs.ProgramsFacilitiesStats](suite.env.Client, suite.T(), http.MethodGet, "/api/programs/stats?days=all", nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: systemAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK). - ExpectMessage("Data fetched successfully") - - result := resp.GetData() - - assert.Equal(suite.T(), int64(0), *result.TotalPrograms) - assert.Equal(suite.T(), int64(0), *result.TotalEnrollments) - assert.Equal(suite.T(), float64(0), *result.AvgActiveProgramsPerFacility) -} - -func (suite *ProgramsHandlerTestSuite) TestProgramsStatsHandler_ResponseStructure() { - facility, systemAdmin, _ := suite.setupTestData() - - // Make request and get raw response to check structure - resp := NewRequest[programs.ProgramsFacilitiesStats](suite.env.Client, suite.T(), http.MethodGet, "/api/programs/stats?days=all", nil). - WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: systemAdmin.ID, FacilityID: facility.ID}). - AsRaw(). - Do(). - ExpectStatus(http.StatusOK) - - // Check that response contains expected fields and NOT the last_run field - resp.ExpectBodyContains("total_programs") - resp.ExpectBodyContains("avg_active_programs_per_facility") - resp.ExpectBodyContains("total_enrollments") - resp.ExpectBodyContains("attendance_rate") - resp.ExpectBodyContains("completion_rate") - resp.ExpectBodyContains("Data fetched successfully") - - // Verify last_run field is NOT present (removed in real-time migration) - assert.NotContains(suite.T(), resp.rawBody, "last_run", "last_run field should be removed") -} - -func TestProgramsHandlerTestSuite(t *testing.T) { - suite.Run(t, new(ProgramsHandlerTestSuite)) -} diff --git a/backend/tests/integration/programs_routes_test.go b/backend/tests/integration/programs_routes_test.go deleted file mode 100644 index 3de0dad90..000000000 --- a/backend/tests/integration/programs_routes_test.go +++ /dev/null @@ -1,770 +0,0 @@ -package integration - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "sort" - "strings" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -// These tests pin what every Programs route answers (EN-194): the status and -// the JSON, and who may call it. They also cover the core routes that read or -// change Programs data. Like the Open Content and Provider Platforms tests, -// they seed with SQL and read raw JSON, and must pass unchanged after the move. -// -// Some routes run SQL that only Postgres understands, so on SQLite they answer -// 500. Those cases still pin the route's access and error shape; make dev -// covers the real answer. -// -// Regenerate the golden files after an intended change with: -// -// go test ./tests/integration -run 'TestPrograms' -update - -// The live Canvas and Essential Education providers, and the IDs the Programs -// routes give their programs and classes. -const ( - canvasProviderID = 10 - essentialEdID = 11 - canvasProgramID = programs.CanvasProgramIDOffset + canvasProviderID - canvasCourseID = 101 - canvasClassID = programs.CanvasClassIDOffset + canvasProviderID*1_000_000 + canvasCourseID - canvasStudentID = "9001" - essentialEdUserID = "8001" -) - -// newFakeLMS stands in for Canvas (under /canvas) and Essential Education -// (under /ee). It serves TLS because the live provider code refuses plain -// HTTP. Background cache refreshes may still call it after a test ends, so it -// answers anything it doesn't know with 404 instead of failing the test. -func newFakeLMS(t *testing.T) *httptest.Server { - t.Helper() - writeJSON := func(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(v) - } - course := map[string]any{ - "id": canvasCourseID, "name": "Intro to Writing", "course_code": "ENG101", - "start_at": "2026-01-05T00:00:00Z", "end_at": "2099-12-31T00:00:00Z", "time_zone": "America/Chicago", - } - mux := http.NewServeMux() - mux.HandleFunc("GET /canvas/api/v1/accounts/1/courses", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, []any{course}) - }) - mux.HandleFunc("GET /canvas/api/v1/courses/101", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, course) - }) - mux.HandleFunc("GET /canvas/api/v1/courses/101/enrollments", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, []any{map[string]any{ - "user": map[string]any{"id": 9001}, "created_at": "2026-01-06T00:00:00Z", "enrollment_state": "active", - }}) - }) - mux.HandleFunc("GET /canvas/api/v1/courses/101/analytics/student_summaries", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, []any{map[string]any{ - "id": 9001, "page_views": 10, "participations": 0, - "tardiness_breakdown": map[string]any{"total": 4, "missing": 2, "on_time": 2, "late": 0}, - }}) - }) - mux.HandleFunc("GET /canvas/api/v1/calendar_events", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, []any{map[string]any{ - "id": 7001, "title": "Week 1", "workflow_state": "active", - "start_at": "2026-02-02T15:00:00Z", "end_at": "2026-02-02T16:00:00Z", - }}) - }) - mux.HandleFunc("GET /canvas/api/v1/users/{id}/courses", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, []any{course}) - }) - mux.HandleFunc("GET /ee/classes", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]any{"at_end": true, "records": []any{map[string]any{ - "id": 201, "name": "GED Math", "open_date": "2026-01-05", "close_date": "2099-12-31", "active": 1, - "students": []any{map[string]any{"id": 8001}}, "schedule_mon": "9:00am-10:00am", - }}}) - }) - mux.HandleFunc("GET /ee/users", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]any{"at_end": true, "records": []any{map[string]any{ - "id": 8001, "first_name": "John", "last_name": "Doe", "username": "ee.jdoe", - }}}) - }) - mux.HandleFunc("/", http.NotFound) - srv := httptest.NewTLSServer(mux) - t.Cleanup(srv.Close) - return srv -} - -type programsWorld struct { - north, south *models.Facility - // jdoe and mmajor are in the North morning cohort; mmajor completed it. - // newbie isn't enrolled anywhere. leaver and bulkLeaver get deactivated. - resident, mmajor, newbie, leaver, bulkLeaver, southResident *models.User - facAdmin, southAdmin, deptAdmin, instructor *models.User -} - -func (w programsWorld) claims(u *models.User) *handlers.Claims { - return &handlers.Claims{UserID: u.ID, Username: u.Username, Role: u.Role, FacilityID: u.FacilityID, TimeZone: "America/Chicago"} -} - -// seedPrograms adds four programs, their classes and cohorts, rooms, events, -// enrollments, attendance and a completion, plus a Canvas and an Essential -// Education provider that point at the fake LMS. -// -// - Program 1 "Adult Literacy" is offered at North and South. Its North -// cohort 1 is Active with weekly Monday sessions in early 2026; South has -// cohort 3. -// - Program 2 "Welding" is offered at North only. Cohort 2 is Scheduled for -// 2099. -// - Program 3 is inactive and archived. Program 4 is offered at South only. -func seedPrograms(t *testing.T, env *TestEnv, lms *httptest.Server) programsWorld { - t.Helper() - t.Setenv("APP_KEY", "test-app-key") - env.Server.Client = lms.Client() - - var w programsWorld - var err error - w.north, err = env.CreateTestFacility("North") - require.NoError(t, err) - w.south, err = env.CreateTestFacility("South") - require.NoError(t, err) - for _, u := range []struct { - user **models.User - username string - role models.UserRole - facility uint - docID string - }{ - {&w.resident, "jdoe", models.Student, w.north.ID, "1001"}, - {&w.mmajor, "mmajor", models.Student, w.north.ID, "1002"}, - {&w.newbie, "newbie", models.Student, w.north.ID, "1003"}, - {&w.leaver, "leaver", models.Student, w.north.ID, "1004"}, - {&w.bulkLeaver, "bulkleaver", models.Student, w.north.ID, "1005"}, - {&w.southResident, "southres", models.Student, w.south.ID, "2001"}, - {&w.facAdmin, "facadmin", models.FacilityAdmin, w.north.ID, ""}, - {&w.southAdmin, "southadmin", models.FacilityAdmin, w.south.ID, ""}, - {&w.deptAdmin, "deptadmin", models.DepartmentAdmin, w.north.ID, ""}, - {&w.instructor, "instructornorth", models.FacilityAdmin, w.north.ID, ""}, - } { - *u.user, err = env.CreateTestUser(u.username, u.role, u.facility, u.docID) - require.NoError(t, err) - } - - day := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - date := func(s string) time.Time { - d, err := time.Parse("2006-01-02", s) - require.NoError(t, err) - return d - } - const providerKey = "test-access-key" - north, south := w.north.ID, w.south.ID - for _, stmt := range []struct { - sql string - args []any - }{ - {`UPDATE users SET name_first = 'John', name_last = 'Doe' WHERE id = ?`, []any{w.resident.ID}}, - {`UPDATE users SET name_first = 'Mary', name_last = 'Major' WHERE id = ?`, []any{w.mmajor.ID}}, - {`UPDATE users SET name_first = 'Ivy', name_last = 'Instructor' WHERE id = ?`, []any{w.instructor.ID}}, - {`INSERT INTO programs (id, name, description, funding_type, is_active, archived_at, has_program_completion, create_user_id, created_at, updated_at) VALUES - (1, 'Adult Literacy', 'Reading and writing', 'Federal_Grants', true, NULL, true, ?, ?, ?), - (2, 'Welding', 'Shop safety and welding', 'State_Grants', true, NULL, false, ?, ?, ?), - (3, 'Old Program', 'Retired', 'Other', false, ?, false, NULL, ?, ?), - (4, 'South Only', 'Offered at South', 'Other', true, NULL, false, NULL, ?, ?)`, - []any{w.deptAdmin.ID, day, day, w.deptAdmin.ID, day, day, day, day, day, day, day}}, - {`INSERT INTO program_types (program_type, program_id) VALUES ('Educational', 1), ('Vocational', 2), ('Life_Skills', 4)`, nil}, - {`INSERT INTO program_credit_types (credit_type, program_id) VALUES ('Completion', 1), ('Earned-time', 2), ('Participation', 4)`, nil}, - {`INSERT INTO facilities_programs (id, program_id, facility_id, created_at, updated_at) VALUES - (1, 1, ?, ?, ?), (2, 1, ?, ?, ?), (3, 2, ?, ?, ?), (4, 3, ?, ?, ?), (5, 4, ?, ?, ?)`, - []any{north, day, day, south, day, day, north, day, day, north, day, day, south, day, day}}, - {`INSERT INTO rooms (id, facility_id, name, created_at, updated_at) VALUES - (1, ?, 'Room A', ?, ?), (2, ?, 'Room B', ?, ?), (3, ?, 'South Hall', ?, ?)`, - []any{north, day, day, north, day, day, south, day, day}}, - {`INSERT INTO program_classes (id, program_id, facility_id, name, description, credit_hours, created_at, updated_at) VALUES - (1, 1, ?, 'Reading 101', 'Morning reading', 2, ?, ?), - (2, 2, ?, 'Welding I', NULL, 3, ?, ?), - (3, 1, ?, 'Reading South', NULL, 2, ?, ?)`, - []any{north, day, day, north, day, day, south, day, day}}, - {`INSERT INTO program_class_credit_types (class_id, credit_type) VALUES (1, 'Completion')`, nil}, - {`INSERT INTO program_class_cohorts (id, class_id, program_id, facility_id, capacity, description, start_dt, end_dt, status, credit_hours, create_user_id, created_at, updated_at) VALUES - (1, 1, 1, ?, 10, 'Morning cohort', ?, ?, 'Active', 2, ?, ?, ?), - (2, 2, 2, ?, 5, 'Evening cohort', ?, ?, 'Scheduled', 3, ?, ?, ?), - (3, 3, 1, ?, 8, 'South cohort', ?, ?, 'Active', 2, NULL, ?, ?)`, - []any{ - north, date("2026-01-05"), date("2099-12-31"), w.facAdmin.ID, day, day, - north, date("2099-01-04"), date("2099-06-30"), w.facAdmin.ID, day, day, - south, date("2026-01-06"), date("2099-12-31"), day, day, - }}, - {`INSERT INTO program_class_enrollments (id, cohort_id, class_id, user_id, enrollment_status, enrolled_at, enrollment_ended_at, created_at, updated_at) VALUES - (1, 1, 1, ?, 'Enrolled', ?, NULL, ?, ?), - (2, 1, 1, ?, 'Completed', ?, ?, ?, ?), - (3, 1, 1, ?, 'Enrolled', ?, NULL, ?, ?), - (4, 1, 1, ?, 'Enrolled', ?, NULL, ?, ?), - (5, 3, 3, ?, 'Enrolled', ?, NULL, ?, ?)`, - []any{ - w.resident.ID, date("2026-01-05"), day, day, - w.mmajor.ID, date("2026-01-05"), date("2026-03-01"), day, day, - w.leaver.ID, date("2026-01-05"), day, day, - w.bulkLeaver.ID, date("2026-01-05"), day, day, - w.southResident.ID, date("2026-01-06"), day, day, - }}, - {`INSERT INTO class_completions (user_id, cohort_id, class_id, facility_name, credit_type, admin_email, program_owner, program_name, program_id, class_name, cohort_start_dt, enrolled_on_dt, created_at, updated_at) - VALUES (?, 1, 1, 'North', 'Completion', 'facadmin@example.com', '', 'Adult Literacy', 1, 'Reading 101', ?, ?, ?, ?)`, - []any{w.mmajor.ID, date("2026-01-05"), date("2026-01-05"), day, day}}, - {`INSERT INTO program_class_events (id, cohort_id, duration, recurrence_rule, room_id, instructor_id, is_cancelled, created_at, updated_at) VALUES - (1, 1, '1h', ?, 1, ?, false, ?, ?), - (2, 2, '1h', ?, 2, ?, false, ?, ?), - (3, 3, '1h', ?, 3, ?, false, ?, ?)`, - []any{ - "DTSTART;TZID=America/Chicago:20260105T090000\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=8", w.instructor.ID, day, day, - "DTSTART;TZID=America/Chicago:20990105T090000\nRRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=4", w.instructor.ID, day, day, - "DTSTART;TZID=America/Chicago:20260106T090000\nRRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=8", w.southAdmin.ID, day, day, - }}, - {`INSERT INTO program_class_event_overrides (id, event_id, duration, override_rrule, is_cancelled, reason, created_at, updated_at) - VALUES (1, 1, '1h', ?, true, 'Holiday', ?, ?)`, - []any{"DTSTART;TZID=America/Chicago:20260112T090000\nRRULE:FREQ=DAILY;COUNT=1", day, day}}, - {`INSERT INTO program_class_event_attendance (event_id, user_id, date, attendance_status, note, create_user_id, created_at, updated_at) VALUES - (1, ?, '2026-01-05', 'present', NULL, ?, ?, ?), - (1, ?, '2026-01-05', 'present', NULL, ?, ?, ?), - (1, ?, '2026-01-19', 'absent_unexcused', 'Sick', ?, ?, ?)`, - []any{w.resident.ID, w.facAdmin.ID, day, day, w.mmajor.ID, w.facAdmin.ID, day, day, w.resident.ID, w.facAdmin.ID, day, day}}, - {`INSERT INTO provider_platforms (id, type, name, account_id, access_key, base_url, state, external_auth_provider_id, created_at, updated_at) VALUES - (?, 'canvas_cloud', 'Canvas College', '1', ?, ?, 'enabled', '', ?, ?), - (?, 'essential_ed', 'Essential Ed', 'district', ?, ?, 'enabled', '', ?, ?)`, - []any{canvasProviderID, providerKey, lms.URL + "/canvas", day, day, essentialEdID, providerKey, lms.URL + "/ee", day, day}}, - {`INSERT INTO provider_user_mappings (user_id, provider_platform_id, external_user_id, external_username, authentication_provider_status, created_at, updated_at) VALUES - (?, ?, ?, 'jdoe', 'none', ?, ?), (?, ?, ?, 'ee.jdoe', 'none', ?, ?)`, - []any{w.resident.ID, canvasProviderID, canvasStudentID, day, day, w.resident.ID, essentialEdID, essentialEdUserID, day, day}}, - } { - require.NoError(t, env.DB.Exec(stmt.sql, stmt.args...).Error, stmt.sql) - } - return w -} - -func TestProgramsRoutes(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - lms := newFakeLMS(t) - w := seedPrograms(t, env, lms) - client := noRedirects(env) - - resident, facAdmin, deptAdmin := w.claims(w.resident), w.claims(w.facAdmin), w.claims(w.deptAdmin) - user := func(format string, u *models.User) string { return fmt.Sprintf(format, u.ID) } - january := "start_dt=2026-01-01&end_dt=2026-01-31" - - // The cases run in order against one database, so later reads show the - // earlier writes. sorted marks routes whose row order isn't fixed: their - // arrays are compared in sorted order. - tests := []struct { - name string - claims *handlers.Claims - method string - path string - body any - status int - sorted bool - }{ - // Programs - {"programs", resident, http.MethodGet, "/api/programs", nil, http.StatusOK, false}, - {"programs_search", facAdmin, http.MethodGet, "/api/programs?search=weld", nil, http.StatusOK, false}, - {"programs_paged", facAdmin, http.MethodGet, "/api/programs?page=2&per_page=2&order_by=name", nil, http.StatusOK, false}, - {"program_filters", resident, http.MethodGet, "/api/programs/filters", nil, http.StatusOK, false}, - {"program_by_id", facAdmin, http.MethodGet, "/api/programs/1", nil, http.StatusOK, false}, - {"program_by_id_bad_id", facAdmin, http.MethodGet, "/api/programs/abc", nil, http.StatusBadRequest, false}, - {"program_by_id_missing", facAdmin, http.MethodGet, "/api/programs/999", nil, http.StatusBadRequest, false}, - {"program_canvas", facAdmin, http.MethodGet, fmt.Sprintf("/api/programs/%d", canvasProgramID), nil, http.StatusOK, false}, - {"programs_detailed_list", facAdmin, http.MethodGet, "/api/programs/detailed-list", nil, http.StatusInternalServerError, false}, - {"programs_stats_facility", facAdmin, http.MethodGet, "/api/programs/stats?days=all", nil, http.StatusOK, false}, - {"programs_stats_all", deptAdmin, http.MethodGet, "/api/programs/stats?days=all", nil, http.StatusOK, false}, - {"program_archive_check", facAdmin, http.MethodGet, "/api/programs/1/archive-check", nil, http.StatusOK, false}, - {"program_delete_check", facAdmin, http.MethodGet, "/api/programs/1/delete-check", nil, http.StatusOK, false}, - {"programs_csv", facAdmin, http.MethodGet, "/api/programs/csv", nil, http.StatusInternalServerError, false}, - {"program_create", facAdmin, http.MethodPost, "/api/programs", - map[string]any{ - "name": "Life Skills", "description": "Everyday skills", "funding_type": "Other", "is_active": true, - "facilities": []uint{w.north.ID}, "program_types": []map[string]any{{"program_type": "Life_Skills"}}, - "credit_types": []map[string]any{{"credit_type": "Participation"}}, - }, http.StatusCreated, false}, - {"program_create_bad_body", facAdmin, http.MethodPost, "/api/programs", "not an object", http.StatusBadRequest, false}, - {"program_create_missing_name", facAdmin, http.MethodPost, "/api/programs", - map[string]any{"description": "d", "funding_type": "Other", "facilities": []uint{w.north.ID}}, http.StatusBadRequest, false}, - {"program_create_duplicate", facAdmin, http.MethodPost, "/api/programs", - map[string]any{"name": "Welding", "description": "d", "funding_type": "Other", "facilities": []uint{w.north.ID}}, http.StatusInternalServerError, false}, - {"program_update", facAdmin, http.MethodPatch, "/api/programs/5", - map[string]any{ - "name": "Life Skills", "description": "Everyday skills for re-entry", "funding_type": "Other", "is_active": true, - "facilities": []uint{w.north.ID, w.south.ID}, "program_types": []map[string]any{{"program_type": "Re-Entry"}}, - "credit_types": []map[string]any{{"credit_type": "Participation"}}, - }, http.StatusOK, false}, - {"program_update_missing", facAdmin, http.MethodPatch, "/api/programs/999", map[string]any{"name": "x", "facilities": []uint{w.north.ID}}, http.StatusBadRequest, false}, - {"program_status_archive_blocked", facAdmin, http.MethodPatch, "/api/programs/1/status", map[string]any{"archived_at": "2026-02-01T00:00:00Z"}, http.StatusOK, false}, - {"program_status_inactive", facAdmin, http.MethodPatch, "/api/programs/5/status", map[string]any{"is_active": false}, http.StatusOK, false}, - {"program_status_missing", facAdmin, http.MethodPatch, "/api/programs/999/status", map[string]any{"is_active": false}, http.StatusBadRequest, false}, - {"program_history", facAdmin, http.MethodGet, "/api/programs/5/history", nil, http.StatusOK, false}, - {"program_history_status", facAdmin, http.MethodGet, "/api/programs/5/history?categories=status", nil, http.StatusOK, false}, - {"program_delete_blocked", facAdmin, http.MethodDelete, "/api/programs/1", nil, http.StatusConflict, false}, - {"program_delete", facAdmin, http.MethodDelete, "/api/programs/5", nil, http.StatusNoContent, false}, - {"programs_after_changes", facAdmin, http.MethodGet, "/api/programs?per_page=50", nil, http.StatusOK, false}, - - // Classes (the tier above cohorts) - {"classes", facAdmin, http.MethodGet, "/api/classes", nil, http.StatusOK, false}, - {"classes_for_program", facAdmin, http.MethodGet, "/api/classes?program_id=1", nil, http.StatusOK, false}, - {"classes_bad_program", facAdmin, http.MethodGet, "/api/classes?program_id=abc", nil, http.StatusBadRequest, false}, - {"class_by_id", resident, http.MethodGet, "/api/classes/1", nil, http.StatusOK, false}, - {"class_by_id_missing", deptAdmin, http.MethodGet, "/api/classes/999", nil, http.StatusBadRequest, false}, - {"class_create", facAdmin, http.MethodPost, "/api/classes", - map[string]any{"program_id": 2, "name": "Welding II", "description": "Advanced", "credit_hours": 3, "credit_types": []string{"Earned-time"}}, - http.StatusCreated, false}, - {"class_create_no_program", facAdmin, http.MethodPost, "/api/classes", map[string]any{"name": "Nothing"}, http.StatusBadRequest, false}, - {"class_update", facAdmin, http.MethodPatch, "/api/classes/4", map[string]any{"description": "Advanced welding", "credit_types": []string{}}, http.StatusOK, false}, - {"class_update_cohort_field", facAdmin, http.MethodPatch, "/api/classes/4", map[string]any{"status": "Active"}, http.StatusBadRequest, false}, - - // Cohorts - {"program_classes", facAdmin, http.MethodGet, "/api/program-classes", nil, http.StatusOK, false}, - {"program_classes_all_facilities", deptAdmin, http.MethodGet, "/api/program-classes?facility=all&search=reading", nil, http.StatusOK, false}, - {"program_cohorts", facAdmin, http.MethodGet, "/api/programs/1/classes", nil, http.StatusInternalServerError, false}, - {"program_cohorts_none", facAdmin, http.MethodGet, "/api/programs/4/classes", nil, http.StatusOK, false}, - {"program_cohorts_canvas", facAdmin, http.MethodGet, fmt.Sprintf("/api/programs/%d/classes", canvasProgramID), nil, http.StatusOK, false}, - {"cohort_by_id", resident, http.MethodGet, "/api/program-classes/1", nil, http.StatusOK, false}, - {"cohort_by_id_missing", deptAdmin, http.MethodGet, "/api/program-classes/999", nil, http.StatusBadRequest, false}, - {"cohort_canvas", facAdmin, http.MethodGet, fmt.Sprintf("/api/program-classes/%d", canvasClassID), nil, http.StatusOK, false}, - {"cohort_create", facAdmin, http.MethodPost, "/api/programs/2/classes", - map[string]any{ - "program_class_id": 2, "description": "Weekend cohort", "status": "Scheduled", "capacity": 4, - "start_dt": "2099-02-01T00:00:00Z", "end_dt": "2099-03-01T00:00:00Z", "credit_hours": 3, "instructor_id": w.instructor.ID, - "events": []map[string]any{{"recurrence_rule": "DTSTART;TZID=America/Chicago:20990206T090000\nRRULE:FREQ=WEEKLY;BYDAY=SA;COUNT=3", "duration": "1h", "room_id": 2}}, - }, http.StatusCreated, false}, - {"cohort_create_no_instructor", facAdmin, http.MethodPost, "/api/programs/2/classes", - map[string]any{"description": "d", "status": "Scheduled", "capacity": 4, "start_dt": "2099-02-01T00:00:00Z"}, http.StatusBadRequest, false}, - {"cohort_create_room_conflict", facAdmin, http.MethodPost, "/api/programs/2/classes", - map[string]any{ - "description": "Clash", "status": "Scheduled", "capacity": 4, "start_dt": "2099-02-01T00:00:00Z", "instructor_id": w.instructor.ID, - "events": []map[string]any{{"recurrence_rule": "DTSTART;TZID=America/Chicago:20990206T090000\nRRULE:FREQ=WEEKLY;BYDAY=SA;COUNT=1", "duration": "1h", "room_id": 2}}, - }, http.StatusConflict, false}, - {"cohort_update", facAdmin, http.MethodPatch, "/api/programs/2/classes/4", map[string]any{"description": "Weekend cohort, small", "capacity": 3}, http.StatusOK, false}, - {"cohort_update_terminal", facAdmin, http.MethodPatch, "/api/programs/1/classes/1", map[string]any{"capacity": 1}, http.StatusBadRequest, false}, - {"cohorts_update", facAdmin, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", 4), map[string]any{"capacity": 6}, http.StatusOK, false}, - {"cohort_history", facAdmin, http.MethodGet, "/api/program-classes/4/history", nil, http.StatusOK, false}, - {"cohort_history_schedule", facAdmin, http.MethodGet, "/api/program-classes/1/history?categories=schedule", nil, http.StatusOK, false}, - {"cohort_attendance_flags", facAdmin, http.MethodGet, "/api/program-classes/1/attendance-flags", nil, http.StatusOK, false}, - {"cohort_attendance_rate", facAdmin, http.MethodGet, "/api/program-classes/1/attendance-rate", nil, http.StatusInternalServerError, false}, - {"cohort_attendance_rate_canvas", facAdmin, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/attendance-rate", canvasClassID), nil, http.StatusOK, false}, - {"cohort_missing_attendance", facAdmin, http.MethodGet, "/api/program-classes/1/missing-attendance", nil, http.StatusInternalServerError, false}, - {"missing_attendance", facAdmin, http.MethodGet, "/api/program-classes/missing-attendance", nil, http.StatusOK, false}, - {"cohort_outcomes", facAdmin, http.MethodGet, "/api/programs/1/classes/outcomes", nil, http.StatusInternalServerError, false}, - {"instructor_classes", facAdmin, http.MethodGet, user("/api/instructors/%d/classes?start_date=2026-01-01&end_date=2026-01-31", w.instructor), nil, http.StatusOK, false}, - {"instructor_classes_no_dates", facAdmin, http.MethodGet, user("/api/instructors/%d/classes", w.instructor), nil, http.StatusBadRequest, false}, - {"bulk_cancel_nothing", facAdmin, http.MethodPost, "/api/program-classes/bulk-cancel", - map[string]any{"instructorId": w.instructor.ID, "startDate": "2030-01-01", "endDate": "2030-01-31", "reason": "Instructor on leave"}, http.StatusOK, false}, - {"bulk_cancel_short_reason", facAdmin, http.MethodPost, "/api/program-classes/bulk-cancel", - map[string]any{"instructorId": w.instructor.ID, "startDate": "2030-01-01", "endDate": "2030-01-31", "reason": "short"}, http.StatusBadRequest, false}, - {"cohort_delete_check", facAdmin, http.MethodGet, "/api/program-classes/4/delete-check", nil, http.StatusOK, false}, - {"cohort_delete_check_blocked", facAdmin, http.MethodGet, "/api/program-classes/1/delete-check", nil, http.StatusOK, false}, - {"cohort_delete_blocked", facAdmin, http.MethodDelete, "/api/program-classes/1", nil, http.StatusConflict, false}, - {"cohort_delete_canvas", facAdmin, http.MethodDelete, fmt.Sprintf("/api/program-classes/%d", canvasClassID), nil, http.StatusBadRequest, false}, - - // Events and calendars - {"cohort_events", facAdmin, http.MethodGet, "/api/program-classes/1/events?month=1&year=2026", nil, http.StatusOK, false}, - {"cohort_events_canvas", facAdmin, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/events", canvasClassID), nil, http.StatusOK, false}, - {"student_calendar", resident, http.MethodGet, "/api/student-calendar?" + january, nil, http.StatusOK, false}, - {"student_calendar_bad_range", resident, http.MethodGet, "/api/student-calendar?start_dt=2026-02-01&end_dt=2026-01-01", nil, http.StatusBadRequest, false}, - {"admin_calendar", facAdmin, http.MethodGet, "/api/admin-calendar?" + january, nil, http.StatusOK, false}, - {"admin_calendar_cohort", facAdmin, http.MethodGet, "/api/admin-calendar?cohort_id=1&" + january, nil, http.StatusOK, false}, - {"admin_calendar_bad_cohort", facAdmin, http.MethodGet, "/api/admin-calendar?cohort_id=abc&" + january, nil, http.StatusBadRequest, false}, - {"todays_schedule", facAdmin, http.MethodGet, "/api/program-classes/todays-schedule", nil, http.StatusOK, false}, - {"canvas_schedule", facAdmin, http.MethodGet, fmt.Sprintf("/api/program-classes/%d/canvas-schedule?start_date=2026-02-01&end_date=2026-02-28", canvasClassID), nil, http.StatusOK, false}, - {"canvas_schedule_not_canvas", facAdmin, http.MethodGet, "/api/program-classes/1/canvas-schedule", nil, http.StatusBadRequest, false}, - {"event_create", facAdmin, http.MethodPost, "/api/program-classes/2/events", - map[string]any{"duration": "1h", "room_id": 1, "instructor_id": w.instructor.ID, - "recurrence_rule": "DTSTART;TZID=America/Chicago:20990107T120000\nRRULE:FREQ=WEEKLY;BYDAY=TH;UNTIL=20990228T000000Z"}, http.StatusCreated, false}, - {"event_create_no_rule", facAdmin, http.MethodPost, "/api/program-classes/2/events", map[string]any{"room_id": 1}, http.StatusInternalServerError, false}, - {"event_series_reschedule", facAdmin, http.MethodPut, "/api/program-classes/2/events", - map[string]any{ - "event_series": map[string]any{"id": 2, "duration": "1h", "room_id": 2, "instructor_id": w.instructor.ID, - "recurrence_rule": "DTSTART;TZID=America/Chicago:20990302T090000\nRRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20990330T000000Z"}, - "closed_event_series": map[string]any{"id": 2, "duration": "1h", "room_id": 2, "instructor_id": w.instructor.ID, - "recurrence_rule": "DTSTART;TZID=America/Chicago:20990105T090000\nRRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20990127T000000Z"}, - }, http.StatusCreated, false}, - {"event_overrides", facAdmin, http.MethodPut, "/api/program-classes/1/events/1", - []map[string]any{{"override_rrule": "DTSTART;TZID=America/Chicago:20260202T090000\nRRULE:FREQ=DAILY;COUNT=1", "duration": "1h", "is_cancelled": true, "reason": "Staff training"}}, - http.StatusOK, false}, - {"event_override_patch", facAdmin, http.MethodPatch, "/api/program-classes/1/events/1", - map[string]any{"date": "2026-02-09", "is_cancelled": true, "reason": "Lockdown"}, http.StatusOK, false}, - {"event_override_patch_no_date", facAdmin, http.MethodPatch, "/api/program-classes/1/events/1", map[string]any{"is_cancelled": true}, http.StatusBadRequest, false}, - {"event_override_uncancel", facAdmin, http.MethodPost, "/api/program-classes/1/events/3/uncancel", nil, http.StatusOK, false}, - {"event_override_delete", facAdmin, http.MethodDelete, "/api/program-classes/1/events/2", nil, http.StatusNoContent, false}, - {"event_uncancel_series", facAdmin, http.MethodPost, "/api/program-classes/1/events/1/uncancel-series", map[string]any{"restore_date": "2026-02-16"}, http.StatusOK, false}, - {"event_uncancel_series_no_date", facAdmin, http.MethodPost, "/api/program-classes/1/events/1/uncancel-series", map[string]any{}, http.StatusBadRequest, false}, - {"cohort_events_after_changes", facAdmin, http.MethodGet, "/api/program-classes/1/events?month=2&year=2026", nil, http.StatusOK, false}, - - // Rooms and instructors - {"rooms", facAdmin, http.MethodGet, "/api/rooms", nil, http.StatusOK, false}, - {"room_create", facAdmin, http.MethodPost, "/api/rooms", map[string]any{"name": "Room C"}, http.StatusCreated, false}, - {"room_create_no_name", facAdmin, http.MethodPost, "/api/rooms", map[string]any{"name": ""}, http.StatusBadRequest, false}, - {"facility_instructors", facAdmin, http.MethodGet, fmt.Sprintf("/api/facilities/%d/instructors", w.north.ID), nil, http.StatusOK, false}, - - // Enrollments - {"enrollments", facAdmin, http.MethodGet, "/api/program-classes/1/enrollments", nil, http.StatusOK, false}, - {"enrollments_by_status", facAdmin, http.MethodGet, "/api/program-classes/1/enrollments?status=Enrolled", nil, http.StatusInternalServerError, false}, - {"enrollment_conflicts", facAdmin, http.MethodPost, "/api/program-classes/2/enrollment-conflicts", map[string]any{"user_ids": []uint{w.resident.ID}}, http.StatusOK, false}, - {"enrollment_conflicts_none", facAdmin, http.MethodPost, "/api/program-classes/2/enrollment-conflicts", map[string]any{"user_ids": []uint{}}, http.StatusOK, false}, - {"users_only_unenrolled", facAdmin, http.MethodGet, "/api/users?include=only_unenrolled&cohort_id=1", nil, http.StatusOK, false}, - {"users_only_enrolled", facAdmin, http.MethodGet, "/api/users?include=only_enrolled&cohort_id=1", nil, http.StatusOK, false}, - {"users_include_bad_cohort", facAdmin, http.MethodGet, "/api/users?include=only_enrolled&cohort_id=abc", nil, http.StatusBadRequest, false}, - {"enroll", facAdmin, http.MethodPost, "/api/program-classes/2/enrollments", map[string]any{"user_ids": []uint{w.resident.ID, w.newbie.ID}, "confirm": true}, http.StatusCreated, false}, - {"enroll_missing_cohort", facAdmin, http.MethodPost, "/api/program-classes/999/enrollments", map[string]any{"user_ids": []uint{w.newbie.ID}, "confirm": true}, http.StatusUnauthorized, false}, - {"enrollments_update", facAdmin, http.MethodPatch, "/api/program-classes/2/enrollments", - map[string]any{"enrollment_status": "Cancelled", "user_ids": []uint{w.newbie.ID}, "change_reason": "Schedule clash"}, http.StatusOK, false}, - {"enrollments_update_invalid", facAdmin, http.MethodPatch, "/api/program-classes/2/enrollments", - map[string]any{"enrollment_status": "Completed", "user_ids": []uint{w.resident.ID}}, http.StatusBadRequest, false}, - {"enrollment_date", facAdmin, http.MethodPatch, "/api/program-classes/1/enrollments/1/date", map[string]any{"enrolled_date": "2026-01-06T00:00:00Z"}, http.StatusOK, false}, - {"enrollment_date_bad", facAdmin, http.MethodPatch, "/api/program-classes/1/enrollments/1/date", map[string]any{"enrolled_date": "Jan 6"}, http.StatusBadRequest, false}, - {"enrollments_after_changes", facAdmin, http.MethodGet, "/api/program-classes/2/enrollments?status=all", nil, http.StatusOK, false}, - {"enrollment_attendance", facAdmin, http.MethodGet, "/api/programs/1/classes/1/enrollments/1/attendance", nil, http.StatusOK, false}, - {"program_completions", facAdmin, http.MethodGet, user("/api/users/%d/program-completions", w.mmajor), nil, http.StatusOK, false}, - {"program_completions_self", w.claims(w.mmajor), http.MethodGet, user("/api/users/%d/program-completions?class_id=1", w.mmajor), nil, http.StatusOK, false}, - - // Attendance - {"event_attendance", facAdmin, http.MethodGet, "/api/program-classes/1/events/1/attendance?date=2026-01-05", nil, http.StatusInternalServerError, false}, - {"event_attendance_cancelled", facAdmin, http.MethodGet, "/api/program-classes/1/events/1/attendance?date=2026-01-12", nil, http.StatusConflict, false}, - {"event_attendance_rate", facAdmin, http.MethodGet, "/api/program-classes/1/events/1/attendance-rate", nil, http.StatusInternalServerError, false}, - {"historical_enrollment", facAdmin, http.MethodGet, "/api/program-classes/1/historical-enrollment-batch?dates=2026-01-05,2026-01-19", nil, http.StatusOK, false}, - {"historical_enrollment_no_dates", facAdmin, http.MethodGet, "/api/program-classes/1/historical-enrollment-batch", nil, http.StatusBadRequest, false}, - {"attendance_add", facAdmin, http.MethodPost, "/api/program-classes/1/events/1/attendance", - []map[string]any{ - {"user_id": w.resident.ID, "date": "2026-01-26", "attendance_status": "present"}, - {"user_id": w.mmajor.ID, "date": "2026-01-26", "attendance_status": "partial", "check_in_at": "09:00", "check_out_at": "09:30"}, - }, http.StatusOK, false}, - {"attendance_add_future", facAdmin, http.MethodPost, "/api/program-classes/1/events/1/attendance", - []map[string]any{{"user_id": w.resident.ID, "date": "2099-01-05", "attendance_status": "present"}}, http.StatusUnprocessableEntity, false}, - {"attendance_add_cancelled", facAdmin, http.MethodPost, "/api/program-classes/1/events/1/attendance", - []map[string]any{{"user_id": w.resident.ID, "date": "2026-01-12", "attendance_status": "present"}}, http.StatusConflict, false}, - {"attendance_delete", facAdmin, http.MethodDelete, user("/api/program-classes/1/events/1/attendance/%d?date=2026-01-26", w.resident), nil, http.StatusNoContent, false}, - {"attendance_delete_not_enrolled", facAdmin, http.MethodDelete, user("/api/program-classes/1/events/1/attendance/%d?date=2026-01-26", w.newbie), nil, http.StatusBadRequest, false}, - {"enrollment_attendance_after_changes", facAdmin, http.MethodGet, "/api/programs/1/classes/1/enrollments/2/attendance", nil, http.StatusOK, false}, - - // Reports - {"report_program_outcomes", facAdmin, http.MethodPost, "/api/reports/generate", - map[string]any{"type": "program_outcomes", "format": "csv", "start_date": "2026-01-01T00:00:00Z", "end_date": "2026-06-30T00:00:00Z", "class_status": "All"}, http.StatusOK, false}, - {"report_class_roster", facAdmin, http.MethodPost, "/api/reports/generate", - map[string]any{"type": "class_roster", "format": "csv", "start_date": "2026-01-01T00:00:00Z", "end_date": "2026-06-30T00:00:00Z", "cohort_id": 1}, http.StatusOK, false}, - {"report_resident_profile", facAdmin, http.MethodPost, "/api/reports/generate", - map[string]any{"type": "resident_profile", "format": "csv", "start_date": "2026-01-01T00:00:00Z", "end_date": "2026-06-30T00:00:00Z", "user_id": w.mmajor.ID}, http.StatusOK, false}, - {"report_bad_type", facAdmin, http.MethodPost, "/api/reports/generate", - map[string]any{"type": "nope", "format": "csv", "start_date": "2026-01-01T00:00:00Z", "end_date": "2026-06-30T00:00:00Z"}, http.StatusBadRequest, false}, - {"report_comparison_facility_admin", facAdmin, http.MethodPost, "/api/reports/generate", - map[string]any{"type": "facility_comparison", "format": "csv", "start_date": "2026-01-01T00:00:00Z", "end_date": "2026-06-30T00:00:00Z"}, http.StatusForbidden, false}, - {"report_roster_no_cohort", facAdmin, http.MethodPost, "/api/reports/generate", - map[string]any{"type": "class_roster", "format": "csv", "start_date": "2026-01-01T00:00:00Z", "end_date": "2026-06-30T00:00:00Z"}, http.StatusBadRequest, false}, - - // Core routes that read Programs data - {"user_programs", resident, http.MethodGet, user("/api/users/%d/programs", w.resident), nil, http.StatusInternalServerError, false}, - {"user_attendance_trend", resident, http.MethodGet, user("/api/users/%d/attendance-trend", w.resident), nil, http.StatusInternalServerError, false}, - {"user_attendance_export", facAdmin, http.MethodGet, user("/api/users/%d/attendance-export?all=true", w.resident), nil, http.StatusOK, false}, - {"facilities", deptAdmin, http.MethodGet, "/api/facilities", nil, http.StatusOK, false}, - {"facility_delete_check", deptAdmin, http.MethodGet, fmt.Sprintf("/api/facilities/%d/delete-check", w.north.ID), nil, http.StatusOK, false}, - {"metrics_engagement", deptAdmin, http.MethodGet, "/api/department-metrics/programs/engagement-overview?facility=all&all_time=true", nil, http.StatusOK, true}, - {"metrics_second_enrollment", deptAdmin, http.MethodGet, "/api/department-metrics/programs/second-enrollment?facility=all", nil, http.StatusOK, true}, - {"metrics_completion_matrix", deptAdmin, http.MethodGet, "/api/department-metrics/programs/completion-matrix?facility=all", nil, http.StatusOK, true}, - {"metrics_load_distribution", deptAdmin, http.MethodGet, "/api/department-metrics/programs/load-distribution?facility=all", nil, http.StatusOK, true}, - {"metrics_enrollment_by_type", deptAdmin, http.MethodGet, "/api/department-metrics/programs/enrollment-by-type?facility=all", nil, http.StatusOK, false}, - {"dashboard_class_metrics", facAdmin, http.MethodGet, "/api/dashboard/class-metrics", nil, http.StatusOK, false}, - {"dashboard_facility_health", deptAdmin, http.MethodGet, "/api/dashboard/facility-health?facility=all", nil, http.StatusOK, true}, - {"resident_verify", facAdmin, http.MethodGet, fmt.Sprintf("/api/users/resident-verify?facility_id=%d&user_id=%d&doc_id=1001", w.south.ID, w.resident.ID), nil, http.StatusOK, false}, - {"user_deactivate", facAdmin, http.MethodPost, user("/api/users/%d/deactivate", w.leaver), nil, http.StatusOK, false}, - {"users_bulk_deactivate", facAdmin, http.MethodPost, "/api/users/bulk/deactivate", map[string]any{"user_ids": []uint{w.bulkLeaver.ID}}, http.StatusOK, false}, - {"enrollments_after_deactivate", facAdmin, http.MethodGet, "/api/program-classes/1/enrollments?status=all", nil, http.StatusOK, false}, - {"account_history_after_deactivate", facAdmin, http.MethodGet, user("/api/users/%d/account-history", w.leaver), nil, http.StatusOK, false}, - {"account_history_attendance", facAdmin, http.MethodGet, user("/api/users/%d/account-history?categories=attendance", w.mmajor), nil, http.StatusOK, false}, - - // Deletes last: this route removes the enrollment whose id is the - // cohort id in the path, so it runs after everything that reads - // enrollments. - {"enrollments_delete", facAdmin, http.MethodDelete, "/api/programs/2/classes/2/enrollments", nil, http.StatusNoContent, false}, - {"cohort_delete", facAdmin, http.MethodDelete, "/api/program-classes/4", nil, http.StatusNoContent, false}, - {"program_classes_after_deletes", facAdmin, http.MethodGet, "/api/program-classes", nil, http.StatusOK, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp := NewRequest[any](client, t, tt.method, tt.path, tt.body). - WithTestClaims(tt.claims).AsRaw().Do(). - ExpectStatus(tt.status) - body := strings.ReplaceAll(resp.rawBody, lms.URL, "https://lms.test") - if tt.sorted { - body = sortArrays(t, body) - } - expectGolden(t, "programs", tt.name, body) - }) - } -} - -// TestProgramsRouteAccess pins each route's access check and its -// program_management flag, the part of a route most easily lost in a move. -// Each route has a path to a North resource ("own" for the North facility -// admin and residents) and, where the check depends on the resource, the same -// route for a South one ("other"). -func TestProgramsRouteAccess(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - lms := newFakeLMS(t) - w := seedPrograms(t, env, lms) - client := noRedirects(env) - - const ( - // anyUser is any logged-in user. - anyUser = iota - // admin is any admin. - admin - // atFacility is any user whose facility holds the resource, and - // department and system admins. - atFacility - // adminAtFacility is admins of the resource's facility, and - // department and system admins. - adminAtFacility - // adminAtClaimsFacility is admins whose own facility holds the - // resource. Department admins are held to their facility too. - adminAtClaimsFacility - // deptAdminForAll is any admin, but only department and system - // admins may add all=true. - deptAdminForAll - // selfOrAdmin is the user in the path, admins of that user's - // facility, and department and system admins. - selfOrAdmin - // residentPrograms is any user, but residents also need the - // resident_programs feature. - residentPrograms - // selfOrAdminResidentPrograms is selfOrAdmin, and residents also need - // the resident_programs feature. - selfOrAdminResidentPrograms - ) - north, south := w.north.ID, w.south.ID - user := func(format string, u *models.User) string { return fmt.Sprintf(format, u.ID) } - - routes := []struct { - method string - own, other string - access int - // gated is whether the route needs program_management. - gated bool - }{ - // Programs - {http.MethodGet, "/api/programs", "", anyUser, true}, - {http.MethodGet, "/api/programs/filters", "", anyUser, true}, - {http.MethodGet, "/api/programs/1", "", anyUser, true}, - {http.MethodGet, "/api/programs/detailed-list", "", admin, true}, - {http.MethodGet, "/api/programs/stats", "", admin, true}, - {http.MethodGet, "/api/programs/1/history", "", admin, true}, - {http.MethodGet, "/api/programs/1/archive-check", "", admin, true}, - {http.MethodGet, "/api/programs/1/delete-check", "", admin, true}, - {http.MethodPost, "/api/programs", "", admin, true}, - {http.MethodDelete, "/api/programs/1", "", admin, true}, - {http.MethodPatch, "/api/programs/1/status", "", admin, true}, - {http.MethodPatch, "/api/programs/1", "", admin, true}, - {http.MethodGet, "/api/programs/csv", "/api/programs/csv?all=true", deptAdminForAll, true}, - - // Classes - {http.MethodGet, "/api/classes", "", anyUser, true}, - {http.MethodGet, "/api/classes/1", "/api/classes/3", atFacility, true}, - {http.MethodPost, "/api/classes", "", admin, true}, - {http.MethodPatch, "/api/classes/1", "/api/classes/3", adminAtFacility, true}, - - // Cohorts - {http.MethodGet, "/api/programs/1/classes", "", anyUser, true}, - {http.MethodGet, "/api/program-classes", "", anyUser, true}, - {http.MethodPost, "/api/programs/2/classes", fmt.Sprintf("/api/programs/4/classes?facility_id=%d", south), adminAtFacility, true}, - {http.MethodGet, user("/api/instructors/%d/classes", w.instructor), user("/api/instructors/%d/classes", w.southAdmin), adminAtFacility, true}, - {http.MethodPost, "/api/program-classes/bulk-cancel", "", admin, true}, - {http.MethodGet, "/api/program-classes/1", "/api/program-classes/3", atFacility, true}, - {http.MethodGet, "/api/programs/2/classes/outcomes", fmt.Sprintf("/api/programs/4/classes/outcomes?facility_id=%d", south), adminAtFacility, true}, - {http.MethodGet, "/api/program-classes/1/attendance-flags", "/api/program-classes/3/attendance-flags", adminAtFacility, true}, - {http.MethodGet, "/api/program-classes/missing-attendance", "", admin, true}, - {http.MethodGet, "/api/program-classes/1/missing-attendance", "/api/program-classes/3/missing-attendance", adminAtFacility, true}, - {http.MethodGet, "/api/program-classes/1/attendance-rate", "/api/program-classes/3/attendance-rate", adminAtFacility, true}, - {http.MethodGet, "/api/program-classes/1/history", "/api/program-classes/3/history", adminAtFacility, true}, - {http.MethodPatch, "/api/program-classes?id=1", "/api/program-classes?id=3", adminAtClaimsFacility, true}, - {http.MethodPatch, "/api/programs/1/classes/1", "/api/programs/1/classes/3", adminAtFacility, true}, - {http.MethodDelete, "/api/program-classes/1", "/api/program-classes/3", adminAtFacility, true}, - {http.MethodGet, "/api/program-classes/1/delete-check", "/api/program-classes/3/delete-check", adminAtFacility, true}, - - // Events and calendars - {http.MethodGet, "/api/student-calendar", "", residentPrograms, true}, - {http.MethodGet, "/api/program-classes/1/events", "", anyUser, true}, - {http.MethodGet, "/api/program-classes/1/canvas-schedule", "", admin, true}, - {http.MethodGet, "/api/admin-calendar", "", admin, true}, - {http.MethodGet, "/api/program-classes/todays-schedule", "", admin, true}, - {http.MethodPut, "/api/program-classes/1/events/1", "/api/program-classes/3/events/3", adminAtFacility, true}, - {http.MethodPatch, "/api/program-classes/1/events/1", "/api/program-classes/3/events/3", adminAtFacility, true}, - {http.MethodDelete, "/api/program-classes/1/events/1", "/api/program-classes/3/events/1", adminAtFacility, true}, - {http.MethodPost, "/api/program-classes/1/events/1/uncancel", "/api/program-classes/3/events/1/uncancel", adminAtFacility, true}, - {http.MethodPost, "/api/program-classes/1/events/1/uncancel-series", "/api/program-classes/3/events/3/uncancel-series", adminAtFacility, true}, - {http.MethodPost, "/api/program-classes/1/events", "/api/program-classes/3/events", adminAtFacility, true}, - {http.MethodPut, "/api/program-classes/1/events", "/api/program-classes/3/events", adminAtFacility, true}, - - // Enrollments - {http.MethodGet, "/api/program-classes/1/enrollments", "", admin, true}, - {http.MethodPost, "/api/program-classes/1/enrollment-conflicts", "", admin, true}, - {http.MethodPost, "/api/program-classes/1/enrollments", "/api/program-classes/3/enrollments", adminAtFacility, true}, - {http.MethodPatch, "/api/program-classes/1/enrollments", "/api/program-classes/3/enrollments", adminAtFacility, true}, - {http.MethodPatch, "/api/program-classes/1/enrollments/1/date", "/api/program-classes/3/enrollments/5/date", adminAtFacility, true}, - {http.MethodDelete, "/api/programs/1/classes/1/enrollments", "/api/programs/1/classes/3/enrollments", adminAtFacility, true}, - {http.MethodGet, "/api/programs/1/classes/1/enrollments/1/attendance", "/api/programs/1/classes/3/enrollments/5/attendance", adminAtFacility, true}, - {http.MethodGet, user("/api/users/%d/program-completions", w.resident), user("/api/users/%d/program-completions", w.southResident), selfOrAdmin, true}, - - // Attendance - {http.MethodGet, "/api/program-classes/1/events/1/attendance", "/api/program-classes/3/events/3/attendance", adminAtFacility, true}, - {http.MethodGet, "/api/program-classes/1/events/1/attendance-rate", "/api/program-classes/3/events/3/attendance-rate", adminAtFacility, true}, - {http.MethodGet, "/api/program-classes/1/historical-enrollment-batch", "/api/program-classes/3/historical-enrollment-batch", adminAtFacility, true}, - {http.MethodPost, "/api/program-classes/1/events/1/attendance", "/api/program-classes/3/events/3/attendance", adminAtFacility, true}, - {http.MethodDelete, user("/api/program-classes/1/events/1/attendance/%d", w.resident), user("/api/program-classes/3/events/3/attendance/%d", w.southResident), adminAtFacility, true}, - - // Reports, rooms and instructors - {http.MethodPost, "/api/reports/generate", "", admin, true}, - {http.MethodGet, "/api/rooms", "", admin, true}, - {http.MethodPost, "/api/rooms", "", admin, true}, - {http.MethodGet, fmt.Sprintf("/api/facilities/%d/instructors", north), fmt.Sprintf("/api/facilities/%d/instructors", south), adminAtFacility, true}, - - // Resident routes in user_handler.go, and the dashboard's Programs - // metrics. None needs program_management. - {http.MethodGet, user("/api/users/%d/programs", w.resident), user("/api/users/%d/programs", w.southResident), selfOrAdminResidentPrograms, false}, - {http.MethodGet, user("/api/users/%d/attendance-trend", w.resident), user("/api/users/%d/attendance-trend", w.southResident), selfOrAdmin, false}, - {http.MethodGet, user("/api/users/%d/attendance-export", w.resident), user("/api/users/%d/attendance-export", w.southResident), adminAtFacility, false}, - {http.MethodGet, "/api/department-metrics/programs/engagement-overview", "", admin, false}, - {http.MethodGet, "/api/department-metrics/programs/second-enrollment", "", admin, false}, - {http.MethodGet, "/api/department-metrics/programs/completion-matrix", "", admin, false}, - {http.MethodGet, "/api/department-metrics/programs/load-distribution", "", admin, false}, - {http.MethodGet, "/api/department-metrics/programs/enrollment-by-type", "", admin, false}, - {http.MethodGet, "/api/dashboard/class-metrics", "", admin, false}, - {http.MethodGet, "/api/dashboard/facility-health", "", admin, false}, - } - - // passes asserts the caller gets past every access check. The body isn't - // valid JSON, so a route that reads one stops there without side effects. - passes := func(t *testing.T, method, path string, c *handlers.Claims) { - t.Helper() - resp := NewRequest[any](client, t, method, path, "not an object"). - WithTestClaims(c).AsRaw().Do() - require.NotEqual(t, http.StatusUnauthorized, resp.resp.StatusCode, resp.rawBody) - } - notAllowed := func(t *testing.T, method, path string, c *handlers.Claims) { - t.Helper() - NewRequest[any](client, t, method, path, map[string]any{}). - WithTestClaims(c).AsRaw().Do(). - ExpectStatus(http.StatusUnauthorized). - ExpectRaw("User is not allowed to view this resource\n") - } - notAdmin := func(t *testing.T, method, path string, c *handlers.Claims) { - t.Helper() - NewRequest[any](client, t, method, path, map[string]any{}). - WithTestClaims(c).Do(). - ExpectStatus(http.StatusUnauthorized).ExpectMessage("Unauthorized - not admin") - } - - resident, southResident := w.claims(w.resident), w.claims(w.southResident) - facAdmin, deptAdmin := w.claims(w.facAdmin), w.claims(w.deptAdmin) - noPrograms := func(c *handlers.Claims) *handlers.Claims { return withFeatures(c, models.ProgramAccess) } - noResidentPrograms := func(c *handlers.Claims) *handlers.Claims { return withFeatures(c, models.ResidentProgramsAccess) } - for _, rt := range routes { - t.Run(rt.method+" "+rt.own, func(t *testing.T) { - // The North facility admin passes every access check on own, so a - // 401 here is the feature check. - if rt.gated { - NewRequest[any](client, t, rt.method, rt.own, map[string]any{}). - WithTestClaims(noPrograms(facAdmin)).Do(). - ExpectStatus(http.StatusUnauthorized).ExpectMessage("Feature not enabled") - } else { - passes(t, rt.method, rt.own, noPrograms(facAdmin)) - } - - switch rt.access { - case anyUser: - passes(t, rt.method, rt.own, resident) - case admin: - notAdmin(t, rt.method, rt.own, resident) - passes(t, rt.method, rt.own, facAdmin) - case atFacility: - passes(t, rt.method, rt.own, resident) - notAllowed(t, rt.method, rt.other, resident) - notAllowed(t, rt.method, rt.other, facAdmin) - passes(t, rt.method, rt.other, deptAdmin) - case adminAtFacility: - notAdmin(t, rt.method, rt.own, resident) - passes(t, rt.method, rt.own, facAdmin) - notAllowed(t, rt.method, rt.other, facAdmin) - passes(t, rt.method, rt.other, deptAdmin) - case adminAtClaimsFacility: - notAdmin(t, rt.method, rt.own, resident) - passes(t, rt.method, rt.own, facAdmin) - notAllowed(t, rt.method, rt.other, facAdmin) - notAllowed(t, rt.method, rt.other, deptAdmin) - case deptAdminForAll: - notAllowed(t, rt.method, rt.own, resident) - passes(t, rt.method, rt.own, facAdmin) - notAllowed(t, rt.method, rt.other, facAdmin) - passes(t, rt.method, rt.other, deptAdmin) - case selfOrAdmin, selfOrAdminResidentPrograms: - passes(t, rt.method, rt.own, resident) - notAllowed(t, rt.method, rt.other, resident) - notAllowed(t, rt.method, rt.other, facAdmin) - passes(t, rt.method, rt.own, facAdmin) - passes(t, rt.method, rt.other, deptAdmin) - passes(t, rt.method, rt.other, southResident) - if rt.access == selfOrAdminResidentPrograms { - notAllowed(t, rt.method, rt.own, noResidentPrograms(resident)) - passes(t, rt.method, rt.own, noResidentPrograms(facAdmin)) - } - case residentPrograms: - passes(t, rt.method, rt.own, resident) - notAllowed(t, rt.method, rt.own, noResidentPrograms(resident)) - passes(t, rt.method, rt.own, noResidentPrograms(facAdmin)) - } - }) - } -} - -// sortArrays orders every JSON array by its elements' JSON, so a route whose -// row order isn't fixed compares the same on every run. -func sortArrays(t *testing.T, body string) string { - t.Helper() - var v any - if err := json.Unmarshal([]byte(body), &v); err != nil { - return body - } - var walk func(any) any - walk = func(v any) any { - switch v := v.(type) { - case map[string]any: - for k, x := range v { - v[k] = walk(x) - } - case []any: - keys := make([]string, len(v)) - for i, x := range v { - v[i] = walk(x) - b, err := json.Marshal(v[i]) - require.NoError(t, err) - keys[i] = string(b) - } - sort.Sort(byKey{keys, v}) - } - return v - } - out, err := json.Marshal(walk(v)) - require.NoError(t, err) - return string(out) -} - -type byKey struct { - keys []string - vals []any -} - -func (b byKey) Len() int { return len(b.keys) } -func (b byKey) Less(i, j int) bool { return b.keys[i] < b.keys[j] } -func (b byKey) Swap(i, j int) { - b.keys[i], b.keys[j] = b.keys[j], b.keys[i] - b.vals[i], b.vals[j] = b.vals[j], b.vals[i] -} diff --git a/backend/tests/integration/programs_stats_test.go b/backend/tests/integration/programs_stats_test.go deleted file mode 100644 index 72364c4da..000000000 --- a/backend/tests/integration/programs_stats_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package integration - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -type ProgramsStatsTestSuite struct { - suite.Suite - env *TestEnv -} - -func (suite *ProgramsStatsTestSuite) SetupSuite() { - suite.env = SetupTestEnv(suite.T()) -} - -func (suite *ProgramsStatsTestSuite) TearDownSuite() { - suite.env.CleanupTestEnv() -} - -func (suite *ProgramsStatsTestSuite) SetupTest() { - // Clean database before each test - suite.cleanDatabase() -} - -func (suite *ProgramsStatsTestSuite) cleanDatabase() { - // Clean up test data in reverse dependency order - suite.env.DB.Exec("DELETE FROM program_class_event_attendance") - suite.env.DB.Exec("DELETE FROM program_class_events") - suite.env.DB.Exec("DELETE FROM program_class_enrollments") - suite.env.DB.Exec("DELETE FROM program_class_cohorts") - suite.env.DB.Exec("DELETE FROM program_class_credit_types") - suite.env.DB.Exec("DELETE FROM program_classes") - suite.env.DB.Exec("DELETE FROM facilities_programs") - suite.env.DB.Exec("DELETE FROM programs") - suite.env.DB.Exec("DELETE FROM facilities") - suite.env.DB.Exec("DELETE FROM users WHERE role != 'system_admin'") -} - -func (suite *ProgramsStatsTestSuite) createTestFacility(name string) *models.Facility { - facility := &models.Facility{ - Name: name, - } - result := suite.env.DB.Create(facility) - assert.NoError(suite.T(), result.Error) - return facility -} - -func (suite *ProgramsStatsTestSuite) createTestProgram(name string) *programs.Program { - program := &programs.Program{ - Name: name, - IsActive: true, - } - result := suite.env.DB.Create(program) - assert.NoError(suite.T(), result.Error) - return program -} - -func (suite *ProgramsStatsTestSuite) createTestUser(facilityID uint, role models.UserRole) *models.User { - timestamp := time.Now().Format("20060102150405") + fmt.Sprintf("%d", time.Now().Nanosecond()) - user := &models.User{ - NameFirst: "Test", - NameLast: "User", - Username: "testuser" + timestamp, - Email: "test" + timestamp + "@example.com", - Role: role, - FacilityID: facilityID, - } - result := suite.env.DB.Create(user) - assert.NoError(suite.T(), result.Error) - return user -} - -func (suite *ProgramsStatsTestSuite) createProgramFacilityAssociation(programID, facilityID uint) { - association := &programs.FacilitiesPrograms{ - FacilityID: facilityID, - ProgramID: programID, - } - result := suite.env.DB.Create(association) - assert.NoError(suite.T(), result.Error) -} - -func (suite *ProgramsStatsTestSuite) createTestClass(programID, facilityID uint, status programs.ClassStatus) *programs.ProgramClassCohort { - class := &programs.ProgramClassCohort{ - ProgramID: programID, - FacilityID: facilityID, - Status: status, - } - result := suite.env.DB.Create(class) - assert.NoError(suite.T(), result.Error) - return class -} - -func (suite *ProgramsStatsTestSuite) createTestEnrollment(classID, userID uint, status programs.ProgramEnrollmentStatus, enrolledAt, endedAt *time.Time) *programs.ProgramClassEnrollment { - enrollment := &programs.ProgramClassEnrollment{ - CohortID: classID, - UserID: userID, - EnrollmentStatus: status, - EnrolledAt: enrolledAt, - EnrollmentEndedAt: endedAt, - } - result := suite.env.DB.Create(enrollment) - assert.NoError(suite.T(), result.Error) - return enrollment -} - -func (suite *ProgramsStatsTestSuite) TestGetProgramsFacilitiesStats_EmptyDatabase() { - ctx := context.Background() - args := &programs.QueryContext{ - Ctx: ctx, - } - - stats, err := suite.env.Programs.GetProgramsFacilitiesStats(args, -1) - - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), int64(0), *stats.TotalPrograms) - assert.Equal(suite.T(), int64(0), *stats.TotalEnrollments) - assert.Equal(suite.T(), float64(0), *stats.AvgActiveProgramsPerFacility) - assert.Equal(suite.T(), float64(0), *stats.AttendanceRate) - assert.Equal(suite.T(), float64(0), *stats.CompletionRate) -} - -func (suite *ProgramsStatsTestSuite) TestGetProgramsFacilitiesStats_BasicScenario() { - // Setup: Create facilities, programs, and associations - facility1 := suite.createTestFacility("Facility 1") - facility2 := suite.createTestFacility("Facility 2") - - program1 := suite.createTestProgram("Program 1") - program2 := suite.createTestProgram("Program 2") - - // Create facility-program associations - suite.createProgramFacilityAssociation(program1.ID, facility1.ID) - suite.createProgramFacilityAssociation(program2.ID, facility1.ID) - suite.createProgramFacilityAssociation(program1.ID, facility2.ID) - - // Create active classes with enrollments - class1 := suite.createTestClass(program1.ID, facility1.ID, programs.Active) - class2 := suite.createTestClass(program2.ID, facility1.ID, programs.Active) - class3 := suite.createTestClass(program1.ID, facility2.ID, programs.Active) - - // Create users for enrollments - student1 := suite.createTestUser(facility1.ID, models.Student) - student2 := suite.createTestUser(facility2.ID, models.Student) - - // Create enrollments - now := time.Now() - suite.createTestEnrollment(class1.ID, student1.ID, programs.Enrolled, &now, nil) - suite.createTestEnrollment(class2.ID, student1.ID, programs.Enrolled, &now, nil) - suite.createTestEnrollment(class3.ID, student2.ID, programs.Enrolled, &now, nil) - - ctx := context.Background() - args := &programs.QueryContext{ - Ctx: ctx, - } - - stats, err := suite.env.Programs.GetProgramsFacilitiesStats(args, -1) - - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), int64(2), *stats.TotalPrograms) // 2 active programs total - assert.Equal(suite.T(), int64(3), *stats.TotalEnrollments) // 3 total enrollments - - // Average programs per facility: 3 program-facility pairs / 2 facilities = 1.5 - assert.Equal(suite.T(), float64(1.5), *stats.AvgActiveProgramsPerFacility) -} - -func (suite *ProgramsStatsTestSuite) TestGetProgramsFacilitiesStats_InactivePrograms() { - // Setup: Create a facility and programs, but make one inactive - facility := suite.createTestFacility("Test Facility") - - activeProgram := suite.createTestProgram("Active Program") - inactiveProgram := &programs.Program{ - Name: "Inactive Program", - IsActive: false, // This should be excluded - } - suite.env.DB.Create(inactiveProgram) - - suite.createProgramFacilityAssociation(activeProgram.ID, facility.ID) - suite.createProgramFacilityAssociation(inactiveProgram.ID, facility.ID) - - // Create classes for both programs - activeClass := suite.createTestClass(activeProgram.ID, facility.ID, programs.Active) - inactiveClass := suite.createTestClass(inactiveProgram.ID, facility.ID, programs.Active) - - student := suite.createTestUser(facility.ID, models.Student) - now := time.Now() - - // Create enrollments for both - suite.createTestEnrollment(activeClass.ID, student.ID, programs.Enrolled, &now, nil) - suite.createTestEnrollment(inactiveClass.ID, student.ID, programs.Enrolled, &now, nil) - - ctx := context.Background() - args := &programs.QueryContext{ - Ctx: ctx, - } - - stats, err := suite.env.Programs.GetProgramsFacilitiesStats(args, -1) - - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), int64(1), *stats.TotalPrograms) // Only active program counted - assert.Equal(suite.T(), int64(2), *stats.TotalEnrollments) // Both enrollments counted - assert.Equal(suite.T(), float64(1), *stats.AvgActiveProgramsPerFacility) // Only active program -} - -func (suite *ProgramsStatsTestSuite) TestGetProgramsFacilitiesStats_ArchivedPrograms() { - // Setup: Create programs, one archived - facility := suite.createTestFacility("Test Facility") - - activeProgram := suite.createTestProgram("Active Program") - - archivedProgram := &programs.Program{ - Name: "Archived Program", - IsActive: true, - ArchivedAt: &time.Time{}, // Archived programs should be excluded - } - suite.env.DB.Create(archivedProgram) - - suite.createProgramFacilityAssociation(activeProgram.ID, facility.ID) - suite.createProgramFacilityAssociation(archivedProgram.ID, facility.ID) - - ctx := context.Background() - args := &programs.QueryContext{ - Ctx: ctx, - } - - stats, err := suite.env.Programs.GetProgramsFacilitiesStats(args, -1) - - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), int64(1), *stats.TotalPrograms) // Only non-archived program -} - -func (suite *ProgramsStatsTestSuite) TestGetProgramsFacilitiesStats_NoActiveClasses() { - // Setup: Programs exist but no active classes with enrollments - facility := suite.createTestFacility("Test Facility") - program := suite.createTestProgram("Test Program") - suite.createProgramFacilityAssociation(program.ID, facility.ID) - - // Create inactive class - suite.createTestClass(program.ID, facility.ID, programs.Cancelled) - - ctx := context.Background() - args := &programs.QueryContext{ - Ctx: ctx, - } - - stats, err := suite.env.Programs.GetProgramsFacilitiesStats(args, -1) - - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), int64(1), *stats.TotalPrograms) // Program exists - assert.Equal(suite.T(), int64(0), *stats.TotalEnrollments) // No enrollments - assert.Equal(suite.T(), float64(0), *stats.AvgActiveProgramsPerFacility) // No qualifying programs -} - -func (suite *ProgramsStatsTestSuite) TestGetProgramsFacilitiesStats_TimeFiltering() { - // Setup: Create enrollments with different completion dates - facility := suite.createTestFacility("Test Facility") - program := suite.createTestProgram("Test Program") - suite.createProgramFacilityAssociation(program.ID, facility.ID) - - class := suite.createTestClass(program.ID, facility.ID, programs.Active) - student := suite.createTestUser(facility.ID, models.Student) - - now := time.Now() - recentDate := now.AddDate(0, 0, -5) // 5 days ago - oldDate := now.AddDate(0, 0, -35) // 35 days ago - - // Create recent completed enrollment - suite.createTestEnrollment(class.ID, student.ID, programs.EnrollmentCompleted, &recentDate, &recentDate) - - // Create old completed enrollment - student2 := suite.createTestUser(facility.ID, models.Student) - suite.createTestEnrollment(class.ID, student2.ID, programs.EnrollmentCompleted, &oldDate, &oldDate) - - ctx := context.Background() - args := &programs.QueryContext{ - Ctx: ctx, - } - - // Test with 30-day filter - should only include recent completion - stats, err := suite.env.Programs.GetProgramsFacilitiesStats(args, 30) - - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), int64(1), *stats.TotalPrograms) - assert.Equal(suite.T(), int64(2), *stats.TotalEnrollments) // Both enrollments counted for total - assert.Greater(suite.T(), *stats.CompletionRate, float64(0)) // Should have completion rate based on time filter -} - -func (suite *ProgramsStatsTestSuite) TestGetProgramsFacilityStats_FacilitySpecific() { - // Setup: Create multiple facilities with programs - facility1 := suite.createTestFacility("Facility 1") - facility2 := suite.createTestFacility("Facility 2") - - program1 := suite.createTestProgram("Program 1") - program2 := suite.createTestProgram("Program 2") - - // Associate programs with both facilities - suite.createProgramFacilityAssociation(program1.ID, facility1.ID) - suite.createProgramFacilityAssociation(program2.ID, facility1.ID) - suite.createProgramFacilityAssociation(program1.ID, facility2.ID) - - // Create active classes with enrollments for both facilities - class1 := suite.createTestClass(program1.ID, facility1.ID, programs.Active) - class2 := suite.createTestClass(program2.ID, facility1.ID, programs.Active) - class3 := suite.createTestClass(program1.ID, facility2.ID, programs.Active) - - student1 := suite.createTestUser(facility1.ID, models.Student) - student2 := suite.createTestUser(facility2.ID, models.Student) - - now := time.Now() - suite.createTestEnrollment(class1.ID, student1.ID, programs.Enrolled, &now, nil) - suite.createTestEnrollment(class2.ID, student1.ID, programs.Enrolled, &now, nil) - suite.createTestEnrollment(class3.ID, student2.ID, programs.Enrolled, &now, nil) - - ctx := context.Background() - args := &programs.QueryContext{ - Ctx: ctx, - FacilityID: facility1.ID, - } - - // Test facility-specific stats for facility1 - stats, err := suite.env.Programs.GetProgramsFacilityStats(args, -1) - - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), int64(2), *stats.TotalPrograms) // 2 programs for facility1 - assert.Equal(suite.T(), int64(2), *stats.TotalEnrollments) // 2 enrollments for facility1 - assert.Equal(suite.T(), float64(2), *stats.AvgActiveProgramsPerFacility) // For single facility, this equals program count -} - -// TestFetchEnrollmentMetrics_CompletionRateCappedAt100 is a regression for ID-716 -// ("Statewide Program View — Avg Completion Rate = 130%"). FetchEnrollmentMetrics counted -// Completed enrollments in the numerator without requiring enrolled_at IS NOT NULL, while the -// denominator required it. Completing a class leaves enrolled_at NULL for enrollments created -// while the class was not Active, so those rows landed in the numerator but not the denominator, -// letting the rate exceed 100%. -func (suite *ProgramsStatsTestSuite) TestFetchEnrollmentMetrics_CompletionRateCappedAt100() { - facility := suite.createTestFacility("ID716 Facility") - program := suite.createTestProgram("ID716 Program") - suite.createProgramFacilityAssociation(program.ID, facility.ID) - - // Class must be Completed for the completion_rate branch to engage. - class := suite.createTestClass(program.ID, facility.ID, programs.Completed) - - now := time.Now() - // Completed enrollment WITH enrolled_at: counts in both numerator and denominator. - s1 := suite.createTestUser(facility.ID, models.Student) - suite.createTestEnrollment(class.ID, s1.ID, programs.EnrollmentCompleted, &now, &now) - // Completed enrollment WITHOUT enrolled_at: pre-fix this was counted in the numerator only, - // pushing the rate to 200%. - s2 := suite.createTestUser(facility.ID, models.Student) - suite.createTestEnrollment(class.ID, s2.ID, programs.EnrollmentCompleted, nil, nil) - - metrics, err := suite.env.Programs.FetchEnrollmentMetrics(int(program.ID), facility.ID) - assert.NoError(suite.T(), err) - if assert.NotNil(suite.T(), metrics.CompletionRate) { - assert.LessOrEqual(suite.T(), *metrics.CompletionRate, float64(100), - "completion rate must never exceed 100%") - assert.Equal(suite.T(), float64(100), *metrics.CompletionRate, - "1 completed-with-enrolled_at / 1 terminal-with-enrolled_at should be 100%") - } -} - -func TestProgramsStatsTestSuite(t *testing.T) { - suite.Run(t, new(ProgramsStatsTestSuite)) -} diff --git a/backend/tests/integration/reports_database_test.go b/backend/tests/integration/reports_database_test.go deleted file mode 100644 index 4ce1b2b52..000000000 --- a/backend/tests/integration/reports_database_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package integration - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestProgramOutcomesReport_ClassAggregation(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility := createTestFacility(t, env, "Test Facility") - program := createTestProgram(t, env, "Math Program", facility.ID) - - createTestClass(t, env, program, facility, "Math 101") - createTestClass(t, env, program, facility, "Math 102") - class3 := createTestClass(t, env, program, facility, "Math 103") - - user := createTestUser(t, env, "student", facility.ID) - createTestEnrollment(t, env, class3.ID, user.ID, programs.Enrolled) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: time.Now().AddDate(0, -2, 0), - EndDate: time.Now(), - } - - rows, err := env.Programs.GenerateProgramOutcomesReport(context.Background(), req) - - require.NoError(t, err) - require.Len(t, rows, 1) - assert.Equal(t, 3, rows[0].TotalClasses, "Should count all 3 active classes without duplication") - assert.Equal(t, 1, rows[0].FacilitiesActive) - assert.Equal(t, 1, rows[0].TotalEnrollments) -} - -func TestProgramOutcomesReport_EnrollmentNoDuplication(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility := createTestFacility(t, env, "Test Facility") - program := createTestProgram(t, env, "English Program", facility.ID) - - class := createTestClass(t, env, program, facility, "English 101") - - user1 := createTestUser(t, env, "student1", facility.ID) - user2 := createTestUser(t, env, "student2", facility.ID) - user3 := createTestUser(t, env, "student3", facility.ID) - - createTestEnrollment(t, env, class.ID, user1.ID, programs.Enrolled) - createTestEnrollment(t, env, class.ID, user2.ID, programs.Enrolled) - createTestEnrollment(t, env, class.ID, user3.ID, programs.EnrollmentCompleted) - - req := &programs.ReportGenerateRequest{ - Type: programs.ProgramOutcomesReport, - StartDate: time.Now().AddDate(0, -2, 0), - EndDate: time.Now(), - } - - rows, err := env.Programs.GenerateProgramOutcomesReport(context.Background(), req) - - require.NoError(t, err) - require.Len(t, rows, 1) - // All-time enrolled counts every status (3); currently enrolled counts - // only "Enrolled" (2). Neither should be multiplied by joins. - assert.Equal(t, 3, rows[0].TotalEnrollments) - assert.Equal(t, 2, rows[0].ActiveEnrollments) -} - -func createTestFacility(t *testing.T, env *TestEnv, name string) *models.Facility { - facility, err := env.CreateTestFacility(name) - require.NoError(t, err) - return facility -} - -func createTestProgram(t *testing.T, env *TestEnv, name string, facilityID uint) *programs.Program { - program, err := env.CreateTestProgram( - name, - programs.FederalGrants, - nil, - nil, - true, - nil, - ) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facilityID}) - require.NoError(t, err) - - programType := programs.ProgramType{ - ProgramType: programs.Educational, - ProgramID: program.ID, - } - err = env.DB.Create(&programType).Error - require.NoError(t, err) - - creditType := programs.ProgramCreditType{ - CreditType: programs.Education, - ProgramID: program.ID, - } - err = env.DB.Create(&creditType).Error - require.NoError(t, err) - - return program -} - -func createTestClass(t *testing.T, env *TestEnv, program *programs.Program, facility *models.Facility, name string) *programs.ProgramClassCohort { - // Create unique instructor prefix from class name (remove spaces and special chars for alphanumunicode validation) - instructorPrefix := strings.ToLower(strings.ReplaceAll(name, " ", "")) - // Remove any non-alphanumeric characters to comply with validation - instructorPrefix = strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - return r - } - return -1 - }, instructorPrefix) - // Fallback if name becomes empty after sanitization - if instructorPrefix == "" { - instructorPrefix = fmt.Sprintf("reports%d", time.Now().UnixNano()) - } - - instructor, err := env.CreateTestInstructor(facility.ID, instructorPrefix) - require.NoError(t, err) - - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - // The displayed name lives on the parent CLASS now -- the cohort has none. Reports - // select it through a join, so renaming the class is what the assertions observe. - err = env.DB.Model(&programs.ProgramClass{}). - Where("id = ?", class.ClassID). - Update("name", name).Error - require.NoError(t, err) - class.ClassName = name - return class -} - -func createTestUser(t *testing.T, env *TestEnv, username string, facilityID uint) *models.User { - user, err := env.CreateTestUser(username, models.Student, facilityID, username+"-123") - require.NoError(t, err) - return user -} - -func createTestEnrollment(t *testing.T, env *TestEnv, classID, userID uint, status programs.ProgramEnrollmentStatus) *programs.ProgramClassEnrollment { - enrolledAt := time.Now().AddDate(0, -1, 0) - enrollment, err := env.CreateTestEnrollmentWithDates(classID, userID, status, enrolledAt, nil) - require.NoError(t, err) - return enrollment -} - -func createTestUserWithName(t *testing.T, env *TestEnv, username, lastName, firstName string, facilityID uint, docID string) *models.User { - user, err := env.CreateTestUser(username, models.Student, facilityID, docID) - require.NoError(t, err) - user.NameLast = lastName - user.NameFirst = firstName - err = env.DB.Save(user).Error - require.NoError(t, err) - return user -} - -func TestGetEnrolledResidentsForClass(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility := createTestFacility(t, env, "Test Facility") - program := createTestProgram(t, env, "Test Program", facility.ID) - class := createTestClass(t, env, program, facility, "Test Class") - - student1 := createTestUserWithName(t, env, "student1", "Smith", "John", facility.ID, "DOC001") - student2 := createTestUserWithName(t, env, "student2", "Jones", "Jane", facility.ID, "DOC002") - student3 := createTestUserWithName(t, env, "student3", "Brown", "Bob", facility.ID, "DOC003") - - createTestEnrollment(t, env, class.ID, student1.ID, programs.Enrolled) - createTestEnrollment(t, env, class.ID, student2.ID, programs.Enrolled) - - args := &models.QueryContext{ - Ctx: context.Background(), - PerPage: 100, - Page: 1, - } - - users, err := enrolledResidents(env, args, class.ID) - - require.NoError(t, err) - require.Len(t, users, 2, "Should return only 2 enrolled students") - - userIDs := make(map[uint]bool) - for _, u := range users { - userIDs[u.ID] = true - assert.Equal(t, models.Student, u.Role, "All returned users should be students") - } - - assert.True(t, userIDs[student1.ID], "Student1 should be in results") - assert.True(t, userIDs[student2.ID], "Student2 should be in results") - assert.False(t, userIDs[student3.ID], "Student3 should NOT be in results (not enrolled)") -} - -func TestGetEnrolledResidentsForClass_ExcludesNonEnrolledStatuses(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility := createTestFacility(t, env, "Test Facility") - program := createTestProgram(t, env, "Test Program", facility.ID) - class := createTestClass(t, env, program, facility, "Test Class") - - student1 := createTestUserWithName(t, env, "student1", "Smith", "John", facility.ID, "DOC123") - student2 := createTestUserWithName(t, env, "student2", "Jones", "Jane", facility.ID, "DOC456") - student3 := createTestUserWithName(t, env, "student3", "Brown", "Bob", facility.ID, "DOC789") - - createTestEnrollment(t, env, class.ID, student1.ID, programs.Enrolled) - createTestEnrollment(t, env, class.ID, student2.ID, programs.EnrollmentCompleted) - createTestEnrollment(t, env, class.ID, student3.ID, programs.EnrollmentIncompleteDropped) - - args := &models.QueryContext{ - Ctx: context.Background(), - PerPage: 100, - Page: 1, - } - - users, err := enrolledResidents(env, args, class.ID) - - require.NoError(t, err) - require.Len(t, users, 1, "Should return only actively enrolled student") - assert.Equal(t, student1.ID, users[0].ID) -} - -// enrolledResidents is the users list's only_enrolled filter: Programs' -// enrolled user IDs, then core's page of those residents. -func enrolledResidents(env *TestEnv, args *models.QueryContext, classID uint) ([]models.User, error) { - ids, err := programs.NewAPI(env.DB.DB).EnrolledUserIDs(args.Ctx, classID) - if err != nil { - return nil, err - } - return env.DB.GetResidentsByIDs(args, ids, "users.name_last asc") -} diff --git a/backend/tests/integration/reports_test.go b/backend/tests/integration/reports_test.go deleted file mode 100644 index de185dd36..000000000 --- a/backend/tests/integration/reports_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package integration - -import ( - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" -) - -func TestAttendanceReportToCSV(t *testing.T) { - seatTime := 90 - absenceReason := "Medical appointment" - recordedBy := "Jane Admin" - - data := []programs.AttendanceReportRow{ - { - FacilityName: "Test Facility", - ProgramName: "Math 101", - ClassName: "Algebra", - Date: time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC), - StudentLastName: "Doe", - StudentFirstName: "John", - DocID: "12345", - AttendanceStatus: programs.Present, - SeatTimeMinutes: &seatTime, - RecordedBy: &recordedBy, - AbsenceReason: nil, - }, - { - FacilityName: "Test Facility", - ProgramName: "Math 101", - ClassName: "Algebra", - Date: time.Date(2024, 3, 16, 0, 0, 0, 0, time.UTC), - StudentLastName: "Smith", - StudentFirstName: "Jane", - DocID: "67890", - AttendanceStatus: programs.Absent_Excused, - SeatTimeMinutes: nil, - RecordedBy: nil, - AbsenceReason: &absenceReason, - }, - } - - report := programs.AttendanceReportData{Data: data} - csv, err := report.ToCSV() - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if len(csv) != 3 { - t.Fatalf("Expected 3 rows (header + 2 data), got %d", len(csv)) - } - - headerRow := csv[0] - expectedHeaders := []string{"Date", "Program Name", "Class Name", "Facility", "Resident Name", "DOC ID", "Attendance Status", "Note"} - if len(headerRow) != len(expectedHeaders) { - t.Fatalf("Expected %d header columns, got %d", len(expectedHeaders), len(headerRow)) - } - for i, expected := range expectedHeaders { - if headerRow[i] != expected { - t.Errorf("Header column %d: expected '%s', got '%s'", i, expected, headerRow[i]) - } - } - - firstDataRow := csv[1] - if firstDataRow[0] != "2024-03-15" { - t.Errorf("Expected date '2024-03-15', got '%s'", firstDataRow[0]) - } - if firstDataRow[1] != "Math 101" { - t.Errorf("Expected program 'Math 101', got '%s'", firstDataRow[1]) - } - if firstDataRow[2] != "Algebra" { - t.Errorf("Expected class 'Algebra', got '%s'", firstDataRow[2]) - } - if firstDataRow[3] != "Test Facility" { - t.Errorf("Expected facility 'Test Facility', got '%s'", firstDataRow[3]) - } - if firstDataRow[4] != "Doe, John" { - t.Errorf("Expected resident 'Doe, John', got '%s'", firstDataRow[4]) - } - if firstDataRow[5] != "12345" { - t.Errorf("Expected DOC ID '12345', got '%s'", firstDataRow[5]) - } - if firstDataRow[7] != "" { - t.Errorf("Expected empty note, got '%s'", firstDataRow[7]) - } - - secondDataRow := csv[2] - if secondDataRow[4] != "Smith, Jane" { - t.Errorf("Expected resident 'Smith, Jane', got '%s'", secondDataRow[4]) - } - if secondDataRow[7] != "Medical appointment" { - t.Errorf("Expected note 'Medical appointment', got '%s'", secondDataRow[7]) - } -} - -func TestProgramOutcomesReportToCSV(t *testing.T) { - data := []programs.ProgramOutcomesReportRow{ - { - ProgramName: "Math Program", - ProgramType: "Educational", - FacilitiesActive: 3, - TotalClasses: 8, - ActiveEnrollments: 60, - TotalEnrollments: 100, - TotalCapacity: 80, - Utilization: 75, - }, - } - - report := programs.ProgramOutcomesReportData{Data: data} - csv, err := report.ToCSV() - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if len(csv) != 2 { - t.Fatalf("Expected 2 rows (header + 1 data), got %d", len(csv)) - } - - // Columns: Program Name, Program Type, Facilities Active, Total Classes, - // Currently Enrolled, All-Time Enrolled, Total Capacity, Utilization % - dataRow := csv[1] - if dataRow[0] != "Math Program" { - t.Errorf("Expected program 'Math Program', got '%s'", dataRow[0]) - } - if dataRow[4] != "60" { - t.Errorf("Expected currently enrolled '60', got '%s'", dataRow[4]) - } - if dataRow[5] != "100" { - t.Errorf("Expected all-time enrolled '100', got '%s'", dataRow[5]) - } - if dataRow[7] != "75" { - t.Errorf("Expected utilization '75', got '%s'", dataRow[7]) - } -} - -func TestFacilityComparisonReportToCSV(t *testing.T) { - lastActivity := time.Date(2024, 3, 20, 0, 0, 0, 0, time.UTC) - - data := []programs.FacilityComparisonReportRow{ - { - FacilityName: "Facility A", - TotalPrograms: 15, - ActivePrograms: 12, - TotalEnrollments: 200, - ActiveEnrollments: 150, - CompletionRate: 45.5, - AttendanceRate: 70.2, - TopProgramType: "Academic", - TotalCreditHours: 1200.5, - CertificatesEarned: 85, - LastActivityDate: &lastActivity, - }, - { - FacilityName: "Facility B", - TotalPrograms: 8, - ActivePrograms: 6, - TotalEnrollments: 120, - ActiveEnrollments: 90, - CompletionRate: 38.0, - AttendanceRate: 62.5, - TopProgramType: "Vocational", - TotalCreditHours: 650.0, - CertificatesEarned: 42, - LastActivityDate: nil, - }, - } - - report := programs.FacilityComparisonReportData{Data: data} - csv, err := report.ToCSV() - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if len(csv) != 3 { - t.Fatalf("Expected 3 rows (header + 2 data), got %d", len(csv)) - } - - firstDataRow := csv[1] - if firstDataRow[0] != "Facility A" { - t.Errorf("Expected facility 'Facility A', got '%s'", firstDataRow[0]) - } - if firstDataRow[10] != "2024-03-20" { - t.Errorf("Expected last activity '2024-03-20', got '%s'", firstDataRow[10]) - } - - secondDataRow := csv[2] - if secondDataRow[10] != "" { - t.Errorf("Expected empty last activity date, got '%s'", secondDataRow[10]) - } -} - -func TestFormatNullableInt(t *testing.T) { - tests := []struct { - name string - input *int - expected string - }{ - {"nil value", nil, ""}, - {"zero value", intPtr(0), "0"}, - {"positive value", intPtr(42), "42"}, - {"negative value", intPtr(-10), "-10"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := programs.FormatNullableInt(tt.input) - if result != tt.expected { - t.Errorf("Expected '%s', got '%s'", tt.expected, result) - } - }) - } -} - -func TestFormatNullableString(t *testing.T) { - tests := []struct { - name string - input *string - expected string - }{ - {"nil value", nil, ""}, - {"empty string", strPtr(""), ""}, - {"normal string", strPtr("test"), "test"}, - {"string with spaces", strPtr("test string"), "test string"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := models.FormatNullableString(tt.input) - if result != tt.expected { - t.Errorf("Expected '%s', got '%s'", tt.expected, result) - } - }) - } -} - -func intPtr(i int) *int { - return &i -} - -func strPtr(s string) *string { - return &s -} diff --git a/backend/tests/integration/resident_programs_feature_test.go b/backend/tests/integration/resident_programs_feature_test.go index 6986cb29c..a892b277b 100644 --- a/backend/tests/integration/resident_programs_feature_test.go +++ b/backend/tests/integration/resident_programs_feature_test.go @@ -1,11 +1,8 @@ package integration import ( - "fmt" - "net/http" "testing" - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" "github.com/UnlockedLabs/unlocked-core/backend/src/models" "github.com/stretchr/testify/require" @@ -64,76 +61,3 @@ func TestResidentProgramsAccess_EffectiveState(t *testing.T) { require.Error(t, err, "the flag isn't toggleable while program tracking is off") }) } - -// Direct navigation to the resident Programs page must not render, which means -// the endpoints behind it have to refuse the resident — not just the nav item -// disappearing client-side. -func TestResidentProgramsAccess_RouteGating(t *testing.T) { - env = SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Resident Programs Route Facility") - require.NoError(t, err) - resident, err := env.CreateTestUser("residentprograms", models.Student, facility.ID, "") - require.NoError(t, err) - - withPage := []models.FeatureAccess{models.ProgramAccess, models.ResidentProgramsAccess} - withoutPage := []models.FeatureAccess{models.ProgramAccess} - - residentClaims := func(features []models.FeatureAccess) *handlers.Claims { - return &handlers.Claims{ - UserID: resident.ID, - Role: models.Student, - FacilityID: facility.ID, - FeatureAccess: features, - } - } - programsURL := fmt.Sprintf("/api/users/%d/programs", resident.ID) - calendarURL := "/api/student-calendar" - - // handleGetUserPrograms runs raw Postgres-only SQL, so it can't return 200 - // against the in-memory SQLite test DB. Asserting "not refused" is what this - // test is actually about: the gate let the request through to the handler. - expectNotRefused := func(t *testing.T, claims *handlers.Claims, url string) { - t.Helper() - resp := NewRequest[any](env.Client, t, http.MethodGet, url, nil). - WithTestClaims(claims). - Do() - require.NotEqual(t, http.StatusUnauthorized, resp.resp.StatusCode, - "the resident_programs gate must not block this caller") - } - - t.Run("resident with the page enabled reaches their programs", func(t *testing.T) { - expectNotRefused(t, residentClaims(withPage), programsURL) - }) - - t.Run("resident with the page enabled reaches the class calendar", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodGet, calendarURL, nil). - WithTestClaims(residentClaims(withPage)). - Do(). - ExpectStatus(http.StatusOK) - }) - - t.Run("resident with the page hidden is refused their programs", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodGet, programsURL, nil). - WithTestClaims(residentClaims(withoutPage)). - Do(). - ExpectStatus(http.StatusUnauthorized) - }) - - t.Run("resident with the page hidden is refused the class calendar", func(t *testing.T) { - NewRequest[any](env.Client, t, http.MethodGet, calendarURL, nil). - WithTestClaims(residentClaims(withoutPage)). - Do(). - ExpectStatus(http.StatusUnauthorized) - }) - - // Hiding the page from residents must not touch any admin-side view. - t.Run("admin without the flag still sees the resident's programs", func(t *testing.T) { - expectNotRefused(t, &handlers.Claims{ - Role: models.FacilityAdmin, - FacilityID: facility.ID, - FeatureAccess: withoutPage, - }, programsURL) - }) -} diff --git a/backend/tests/integration/room_conflict_test.go b/backend/tests/integration/room_conflict_test.go deleted file mode 100644 index cfcd231b9..000000000 --- a/backend/tests/integration/room_conflict_test.go +++ /dev/null @@ -1,564 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestRoomConflictDetection(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Conflict Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("conflictadmin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Conflict Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - t.Run("Detects overlapping bookings", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Conflict Room 1"} - require.NoError(t, env.DB.Create(room).Error) - testDetectsOverlappingBookings(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Excludes own event ID when updating", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Conflict Room 2"} - require.NoError(t, env.DB.Create(room).Error) - testExcludesOwnEventID(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("No conflict for different times", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Conflict Room 3"} - require.NoError(t, env.DB.Create(room).Error) - testNoConflictForDifferentTimes(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Handler returns 409 on room conflict", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Conflict Room 4"} - require.NoError(t, env.DB.Create(room).Error) - testHandlerReturns409OnConflict(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Cancelling a series bypasses room conflict check", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "Cancel Bypass Room"} - require.NoError(t, env.DB.Create(room).Error) - testCancelSeriesBypassesConflict(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Rejects room from different facility", func(t *testing.T) { - otherFacility, err := env.CreateTestFacility("Other Facility") - require.NoError(t, err) - otherRoom := &programs.Room{FacilityID: otherFacility.ID, Name: "Other Room"} - require.NoError(t, env.DB.Create(otherRoom).Error) - testRejectsRoomFromDifferentFacility(t, env, facility, facilityAdmin, program, otherRoom.ID) - }) - - t.Run("Detects conflicts with timezone-aware RRULEs across DST", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "DST Test Room"} - require.NoError(t, env.DB.Create(room).Error) - testDetectsConflictsWithTimezoneAwareRRules(t, env, facility, facilityAdmin, program, room.ID) - }) - - t.Run("Backwards compatible with UTC-based RRULEs", func(t *testing.T) { - room := &programs.Room{FacilityID: facility.ID, Name: "UTC Compat Room"} - require.NoError(t, env.DB.Create(room).Error) - testBackwardsCompatibleUTCRules(t, env, facility, facilityAdmin, program, room.ID) - }) -} - -func testDetectsOverlappingBookings(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "conflict1") - require.NoError(t, err) - - classStartDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class1 := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor.ID, - Description: "First class to book the room", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - class1Resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class1). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass1 := class1Resp.GetData() - require.NoError(t, env.NameCohortClass(createdClass1.ID, "First Class")) - - event1Payload := map[string]interface{}{ - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260302T090000Z\nRRULE:FREQ=DAILY;UNTIL=20260331T000000Z", - "instructor_id": instructor.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass1.ID), event1Payload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - conflicts, err := env.Programs.CheckRRuleConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - RoomID: roomID, - RecurrenceRule: "DTSTART:20260302T093000Z\nRRULE:FREQ=DAILY;UNTIL=20260310T000000Z", - Duration: "1h", - }) - require.NoError(t, err) - require.NotEmpty(t, conflicts, "Should detect conflicts for overlapping times") - require.Equal(t, "First Class", conflicts[0].ClassName, "Conflict should identify the class name") -} - -func testExcludesOwnEventID(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "conflict2") - require.NoError(t, err) - - classStartDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor.ID, - Description: "Class for testing self-exclusion", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - classResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass := classResp.GetData() - - eventPayload := map[string]interface{}{ - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260402T140000Z\nRRULE:FREQ=DAILY;UNTIL=20260430T000000Z", - "instructor_id": instructor.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - var event programs.ProgramClassEvent - err = env.DB.Where("cohort_id = ?", createdClass.ID).First(&event).Error - require.NoError(t, err) - - conflictsWithoutExclusion, err := env.Programs.CheckRRuleConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - RoomID: roomID, - RecurrenceRule: "DTSTART:20260402T140000Z\nRRULE:FREQ=DAILY;UNTIL=20260410T000000Z", - Duration: "2h", - }) - require.NoError(t, err) - require.NotEmpty(t, conflictsWithoutExclusion, "Should detect conflicts when not excluding own event") - - conflictsWithExclusion, err := env.Programs.CheckRRuleConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - RoomID: roomID, - RecurrenceRule: "DTSTART:20260402T140000Z\nRRULE:FREQ=DAILY;UNTIL=20260410T000000Z", - Duration: "2h", - ExcludeEventID: &event.ID, - }) - require.NoError(t, err) - require.Empty(t, conflictsWithExclusion, "Should NOT detect conflicts when excluding own event ID") -} - -func testNoConflictForDifferentTimes(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "conflict3") - require.NoError(t, err) - - classStartDate := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor.ID, - Description: "Morning class", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - classResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass := classResp.GetData() - - eventPayload := map[string]interface{}{ - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260504T090000Z\nRRULE:FREQ=DAILY;UNTIL=20260531T000000Z", - "instructor_id": instructor.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - conflicts, err := env.Programs.CheckRRuleConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - RoomID: roomID, - RecurrenceRule: "DTSTART:20260504T140000Z\nRRULE:FREQ=DAILY;UNTIL=20260510T000000Z", - Duration: "2h", - }) - require.NoError(t, err) - require.Empty(t, conflicts, "Should NOT detect conflicts for non-overlapping times (9-11 AM vs 2-4 PM)") -} - -// testCancelSeriesBypassesConflict verifies that cancelling a series (event_series -// with is_cancelled=true) is NOT blocked by a room conflict — a cancellation frees -// the room rather than booking it. -func testCancelSeriesBypassesConflict(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor1, err := env.CreateTestInstructor(facility.ID, "cancelbypassa") - require.NoError(t, err) - instructor2, err := env.CreateTestInstructor(facility.ID, "cancelbypassb") - require.NoError(t, err) - - classStartDate := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - mkClass := func(name string, instructorID uint) *programs.ProgramClassCohort { - resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructorID, - Description: name, - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - }). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - return resp.GetData() - } - - // Blocker class occupies the room daily at 10:00-12:00. - blocker := mkClass("Room Blocker", instructor1.ID) - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", blocker.ID), map[string]interface{}{ - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260601T100000Z\nRRULE:FREQ=DAILY;UNTIL=20260630T000000Z", - "instructor_id": instructor1.ID, - }). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - // Second class with a non-overlapping event (14:00) so creation succeeds. - toCancel := mkClass("Class To Cancel", instructor2.ID) - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", toCancel.ID), map[string]interface{}{ - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260601T140000Z\nRRULE:FREQ=DAILY;UNTIL=20260630T000000Z", - "instructor_id": instructor2.ID, - }). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - var toCancelEvent programs.ProgramClassEvent - require.NoError(t, env.DB.Where("cohort_id = ?", toCancel.ID).First(&toCancelEvent).Error) - - // Cancel the series. The cancelled event_series carries the room and a rule that - // overlaps the blocker (10:00) — pre-fix this returned 409, now it must succeed. - cancelPayload := map[string]interface{}{ - "event_series": map[string]interface{}{ - "id": 0, - "duration": "2h", - "room_id": roomID, - "instructor_id": instructor2.ID, - "is_cancelled": true, - "recurrence_rule": "DTSTART:20260601T100000Z\nRRULE:FREQ=DAILY;UNTIL=20260630T000000Z", - }, - "closed_event_series": map[string]interface{}{ - "id": toCancelEvent.ID, - "recurrence_rule": "DTSTART:20260601T140000Z\nRRULE:FREQ=DAILY;UNTIL=20260531T235959Z", - }, - } - NewRequest[any](env.Client, t, http.MethodPut, fmt.Sprintf("/api/program-classes/%d/events", toCancel.ID), cancelPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) -} - -func testHandlerReturns409OnConflict(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - instructor1, err := env.CreateTestInstructor(facility.ID, "conflict4a") - require.NoError(t, err) - - instructor2, err := env.CreateTestInstructor(facility.ID, "conflict4b") - require.NoError(t, err) - - classStartDate := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class1 := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor1.ID, - Description: "Class that already has the room", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - class1Resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class1). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass1 := class1Resp.GetData() - - event1Payload := map[string]interface{}{ - "duration": "2h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260601T100000Z\nRRULE:FREQ=DAILY;UNTIL=20260630T000000Z", - "instructor_id": instructor1.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass1.ID), event1Payload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - class2 := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor2.ID, - Description: "Class that tries to book the same room", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - class2Resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class2). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass2 := class2Resp.GetData() - - conflictingEventPayload := map[string]interface{}{ - "duration": "1h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260601T103000Z\nRRULE:FREQ=DAILY;UNTIL=20260610T000000Z", - "instructor_id": instructor2.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass2.ID), conflictingEventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusConflict) -} - -func testRejectsRoomFromDifferentFacility(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, otherFacilityRoomID uint) { - instructor, err := env.CreateTestInstructor(facility.ID, "conflict5") - require.NoError(t, err) - - classStartDate := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) - classEndDate := time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor.ID, - Description: "Class trying to use room from different facility", - StartDt: classStartDate, - EndDt: &classEndDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - classResp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass := classResp.GetData() - - eventPayload := map[string]interface{}{ - "duration": "2h", - "room_id": otherFacilityRoomID, - "recurrence_rule": "DTSTART:20260702T100000Z\nRRULE:FREQ=DAILY;UNTIL=20260731T000000Z", - "instructor_id": instructor.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusBadRequest) -} - -// testDetectsConflictsWithTimezoneAwareRRules tests that timezone-aware RRULEs -// correctly detect conflicts across DST transitions. Two classes at "10 AM local time" -// created in different DST periods should conflict when using DTSTART;TZID= format. -func testDetectsConflictsWithTimezoneAwareRRules(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - // Use America/New_York timezone for DST testing - facility.Timezone = "America/New_York" - require.NoError(t, env.DB.Save(facility).Error) - - instructor, err := env.CreateTestInstructor(facility.ID, "conflict6") - require.NoError(t, err) - - // Class 1: Created in July (EDT) at 10 AM local time, repeating on Thursdays - // Using timezone-aware RRULE format: DTSTART;TZID=America/New_York:20250701T100000 - classStartDate := time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class1 := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor.ID, - Description: "Class created in summer at 10 AM", - StartDt: classStartDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - class1Resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class1). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass1 := class1Resp.GetData() - require.NoError(t, env.NameCohortClass(createdClass1.ID, "Summer Class")) - - // Timezone-aware RRULE: 10 AM in America/New_York, every Thursday - event1Payload := map[string]interface{}{ - "duration": "1h", - "room_id": roomID, - "recurrence_rule": "DTSTART;TZID=America/New_York:20250703T100000\nRRULE:FREQ=WEEKLY;BYDAY=TH", - "instructor_id": instructor.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass1.ID), event1Payload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - // Now try to create Class 2 in December (EST) at 10 AM local time - // With timezone-aware RRULEs, this should conflict because both are at 10 AM wall time - conflicts, err := env.Programs.CheckRRuleConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - RoomID: roomID, - RecurrenceRule: "DTSTART;TZID=America/New_York:20251204T100000\nRRULE:FREQ=WEEKLY;BYDAY=TH", - Duration: "1h", - }) - require.NoError(t, err) - require.NotEmpty(t, conflicts, "Should detect conflicts when both classes are at 10 AM wall time (timezone-aware RRULEs preserve wall time across DST)") - require.Equal(t, "Summer Class", conflicts[0].ClassName, "Conflict should identify the Summer Class") - - // Verify that classes at different wall times don't conflict - // 11 AM local time should NOT conflict with 10 AM class - noConflicts, err := env.Programs.CheckRRuleConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - RoomID: roomID, - RecurrenceRule: "DTSTART;TZID=America/New_York:20251204T110000\nRRULE:FREQ=WEEKLY;BYDAY=TH", - Duration: "1h", - }) - require.NoError(t, err) - require.Empty(t, noConflicts, "Should NOT detect conflicts when classes are at different wall times (10 AM vs 11 AM)") -} - -// testBackwardsCompatibleUTCRules verifies that old UTC-based RRULEs still work. -// Note: UTC-based RRULEs will have the DST shift behavior (this is expected for legacy data). -func testBackwardsCompatibleUTCRules(t *testing.T, env *TestEnv, facility *models.Facility, facilityAdmin *models.User, program *programs.Program, roomID uint) { - facility.Timezone = "America/New_York" - require.NoError(t, env.DB.Save(facility).Error) - - instructor, err := env.CreateTestInstructor(facility.ID, "conflict7") - require.NoError(t, err) - - classStartDate := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) - creditHours := int64(2) - - class1 := programs.ProgramClassCohort{ - ProgramID: program.ID, - FacilityID: facility.ID, - Capacity: 10, - InstructorID: &instructor.ID, - Description: "Class using old UTC-based RRULE format", - StartDt: classStartDate, - Status: programs.Scheduled, - CreditHours: &creditHours, - } - - class1Resp := NewRequest[*programs.ProgramClassCohort](env.Client, t, http.MethodPost, fmt.Sprintf("/api/programs/%d/classes", program.ID), class1). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - createdClass1 := class1Resp.GetData() - - // Old UTC-based RRULE format: 14:00 UTC = 10:00 AM EDT in August - event1Payload := map[string]interface{}{ - "duration": "1h", - "room_id": roomID, - "recurrence_rule": "DTSTART:20260806T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=TH", - "instructor_id": instructor.ID, - } - - NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass1.ID), event1Payload). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusCreated) - - // Another UTC-based RRULE at the same UTC time should conflict - conflicts, err := env.Programs.CheckRRuleConflicts(&programs.ConflictCheckRequest{ - FacilityID: facility.ID, - RoomID: roomID, - RecurrenceRule: "DTSTART:20260806T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=TH", - Duration: "1h", - }) - require.NoError(t, err) - require.NotEmpty(t, conflicts, "UTC-based RRULEs at same UTC time should still conflict (backwards compatibility)") -} diff --git a/backend/tests/integration/rrule_until_integration_test.go b/backend/tests/integration/rrule_until_integration_test.go deleted file mode 100644 index 660f36cbd..000000000 --- a/backend/tests/integration/rrule_until_integration_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "strings" - "testing" - "time" - - "github.com/UnlockedLabs/unlocked-core/backend/src/handlers" - "github.com/UnlockedLabs/unlocked-core/backend/src/models" - "github.com/UnlockedLabs/unlocked-core/backend/src/modules/programs" - - "github.com/stretchr/testify/require" -) - -func TestClassCompletedAddsUntilDate(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("admin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "rrule") - require.NoError(t, err) - - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - indefiniteRRule := "DTSTART:20240101T100000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH" - event, err := env.CreateTestEventWithRRule(class.ID, indefiniteRRule, instructor.ID) - require.NoError(t, err) - - originalRule, err := env.GetEventRecurrenceRule(event.ID) - require.NoError(t, err) - require.Equal(t, indefiniteRRule, originalRule) - require.Empty(t, programs.GetUntilDateFromRule(originalRule)) - - completionTime := time.Now() - updateData := map[string]any{ - "status": string(programs.Completed), - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedRule, err := env.GetEventRecurrenceRule(event.ID) - require.NoError(t, err) - require.Contains(t, updatedRule, "UNTIL=") - - untilDate := programs.GetUntilDateFromRule(updatedRule) - require.NotEmpty(t, untilDate) - - parsedUntil, err := time.Parse("20060102T150405Z", untilDate) - require.NoError(t, err) - - expectedDay := time.Date(completionTime.Year(), completionTime.Month(), completionTime.Day(), 23, 59, 59, 0, time.UTC) - nextDay := expectedDay.AddDate(0, 0, 1) - - require.True(t, parsedUntil.Equal(expectedDay) || parsedUntil.Equal(nextDay)) - require.True(t, strings.HasSuffix(untilDate, "T235959Z")) -} - -func TestClassCancelledAddsUntilDate(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("admin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "rrule") - require.NoError(t, err) - - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - indefiniteRRule := "DTSTART:20240715T150000Z\nRRULE:FREQ=DAILY;INTERVAL=2" - event, err := env.CreateTestEventWithRRule(class.ID, indefiniteRRule, instructor.ID) - require.NoError(t, err) - - originalRule, err := env.GetEventRecurrenceRule(event.ID) - require.NoError(t, err) - require.Equal(t, indefiniteRRule, originalRule) - require.Empty(t, programs.GetUntilDateFromRule(originalRule)) - - cancellationTime := time.Now() - updateData := map[string]any{ - "status": string(programs.Cancelled), - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedRule, err := env.GetEventRecurrenceRule(event.ID) - require.NoError(t, err) - require.Contains(t, updatedRule, "UNTIL=") - - untilDate := programs.GetUntilDateFromRule(updatedRule) - require.NotEmpty(t, untilDate) - - parsedUntil, err := time.Parse("20060102T150405Z", untilDate) - require.NoError(t, err) - - expectedDay := time.Date(cancellationTime.Year(), cancellationTime.Month(), cancellationTime.Day(), 23, 59, 59, 0, time.UTC) - nextDay := expectedDay.AddDate(0, 0, 1) - - require.True(t, parsedUntil.Equal(expectedDay) || parsedUntil.Equal(nextDay)) - require.True(t, strings.HasSuffix(untilDate, "T235959Z")) -} - -func TestMultipleEventsGetUntilDate(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("admin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "rrule") - require.NoError(t, err) - - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - event1, err := env.CreateTestEventWithRRule(class.ID, "DTSTART:20240721T100000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR", instructor.ID) - require.NoError(t, err) - - event2, err := env.CreateTestEventWithRRule(class.ID, "DTSTART:20240722T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH", instructor.ID) - require.NoError(t, err) - - rule1, err := env.GetEventRecurrenceRule(event1.ID) - require.NoError(t, err) - require.Empty(t, programs.GetUntilDateFromRule(rule1)) - - rule2, err := env.GetEventRecurrenceRule(event2.ID) - require.NoError(t, err) - require.Empty(t, programs.GetUntilDateFromRule(rule2)) - - completionTime := time.Now() - updateData := map[string]any{ - "status": string(programs.Completed), - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedRule1, err := env.GetEventRecurrenceRule(event1.ID) - require.NoError(t, err) - require.Contains(t, updatedRule1, "UNTIL=") - - updatedRule2, err := env.GetEventRecurrenceRule(event2.ID) - require.NoError(t, err) - require.Contains(t, updatedRule2, "UNTIL=") - - untilDate1 := programs.GetUntilDateFromRule(updatedRule1) - untilDate2 := programs.GetUntilDateFromRule(updatedRule2) - - parsedUntil1, err := time.Parse("20060102T150405Z", untilDate1) - require.NoError(t, err) - parsedUntil2, err := time.Parse("20060102T150405Z", untilDate2) - require.NoError(t, err) - - expectedDay := time.Date(completionTime.Year(), completionTime.Month(), completionTime.Day(), 23, 59, 59, 0, time.UTC) - nextDay := expectedDay.AddDate(0, 0, 1) - - require.True(t, parsedUntil1.Equal(expectedDay) || parsedUntil1.Equal(nextDay)) - require.True(t, parsedUntil2.Equal(expectedDay) || parsedUntil2.Equal(nextDay)) - require.True(t, strings.HasSuffix(untilDate1, "T235959Z")) - require.True(t, strings.HasSuffix(untilDate2, "T235959Z")) -} - -func TestExistingUntilDateReplaced(t *testing.T) { - env := SetupTestEnv(t) - defer env.CleanupTestEnv() - - facility, err := env.CreateTestFacility("Test Facility") - require.NoError(t, err) - - facilityAdmin, err := env.CreateTestUser("admin", models.FacilityAdmin, facility.ID, "") - require.NoError(t, err) - - program, err := env.CreateTestProgram("Test Program", programs.FundingType(programs.FederalGrants), []programs.ProgramType{}, []programs.ProgramCreditType{}, true, nil) - require.NoError(t, err) - - err = env.SetFacilitiesToProgram(program.ID, []uint{facility.ID}) - require.NoError(t, err) - - instructor, err := env.CreateTestInstructor(facility.ID, "rrule") - require.NoError(t, err) - - class, err := env.CreateTestClass(program, facility, programs.Active, &instructor.ID) - require.NoError(t, err) - - futureDate := time.Now().AddDate(1, 0, 0) - existingUntilDate := programs.FormatDateForUntil(futureDate) - ruleWithExistingUntil := fmt.Sprintf("DTSTART:20240721T100000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=%s", existingUntilDate) - - event, err := env.CreateTestEventWithRRule(class.ID, ruleWithExistingUntil, instructor.ID) - require.NoError(t, err) - - originalRule, err := env.GetEventRecurrenceRule(event.ID) - require.NoError(t, err) - require.Contains(t, originalRule, "UNTIL="+existingUntilDate) - - completionTime := time.Now() - updateData := map[string]any{ - "status": string(programs.Completed), - } - - NewRequest[any](env.Client, t, http.MethodPatch, fmt.Sprintf("/api/program-classes?id=%d", class.ID), updateData). - WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, UserID: facilityAdmin.ID, FacilityID: facility.ID}). - Do(). - ExpectStatus(http.StatusOK) - - updatedRule, err := env.GetEventRecurrenceRule(event.ID) - require.NoError(t, err) - - newUntilDate := programs.GetUntilDateFromRule(updatedRule) - - parsedNewUntil, err := time.Parse("20060102T150405Z", newUntilDate) - require.NoError(t, err) - - expectedDay := time.Date(completionTime.Year(), completionTime.Month(), completionTime.Day(), 23, 59, 59, 0, time.UTC) - nextDay := expectedDay.AddDate(0, 0, 1) - - require.True(t, parsedNewUntil.Equal(expectedDay) || parsedNewUntil.Equal(nextDay)) - require.True(t, strings.HasSuffix(newUntilDate, "T235959Z")) - require.NotEqual(t, existingUntilDate, newUntilDate) - require.NotContains(t, updatedRule, existingUntilDate) -} diff --git a/backend/tests/integration/testdata/programs/admin_calendar.golden b/backend/tests/integration/testdata/programs/admin_calendar.golden deleted file mode 100644 index ea02ec12b..000000000 --- a/backend/tests/integration/testdata/programs/admin_calendar.golden +++ /dev/null @@ -1,129 +0,0 @@ -{ - "data": [ - { - "attendees": null, - "class_status": "Active", - "cohort": null, - "cohort_id": 1, - "create_user_id": null, - "created_at": "