1use chrono::Duration;
9
10use crate::domain::credit_registration_phases::{
11 CreditRegistrationPhase, PhaseContext, PhaseScope, PhaseSkipReason, PhaseTick, run_phase_once,
12};
13use crate::prelude::*;
14use headless_lms_utils::services::suotar::SuotarClient;
15use sqlx::PgPool;
16
17use super::commands;
18
19#[derive(Debug, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct RunTickQuery {
22 pub phase: Option<String>,
24 pub course_id: Option<Uuid>,
25 pub course_slug: Option<String>,
27 pub user_id: Option<Uuid>,
28 pub user_email: Option<String>,
29 pub credit_registration_ids: Option<String>,
31}
32
33#[derive(Debug, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub struct UnresolvedScope {
36 pub status: String,
37 pub half: String,
38 pub value: String,
39}
40
41#[derive(Debug, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "camelCase", tag = "status")]
43pub enum PhaseTickResult {
44 #[serde(rename_all = "camelCase")]
45 Ran {
46 phase: String,
47 items_processed: i32,
48 items_failed: i32,
49 error: Option<String>,
51 },
52 Skipped { phase: String, reason: String },
54 ScopeNotSupported { phase: String },
56 #[serde(rename_all = "camelCase")]
57 UnknownPhase {
58 phase: Option<String>,
59 known_phases: Vec<String>,
60 },
61}
62
63impl PhaseTickResult {
64 fn of(phase: CreditRegistrationPhase, tick: PhaseTick) -> Self {
65 match tick {
66 PhaseTick::Ran(outcome) => Self::Ran {
67 phase: phase.as_str().to_string(),
68 items_processed: outcome.items_processed,
69 items_failed: outcome.items_failed,
70 error: outcome.error,
71 },
72 PhaseTick::Skipped(reason) => Self::Skipped {
73 phase: phase.as_str().to_string(),
74 reason: match reason {
75 PhaseSkipReason::Paused => "paused".to_string(),
76 PhaseSkipReason::CircuitBreakerOpen => "circuitBreakerOpen".to_string(),
77 },
78 },
79 PhaseTick::ScopeNotSupported => Self::ScopeNotSupported {
80 phase: phase.as_str().to_string(),
81 },
82 }
83 }
84}
85
86#[derive(Debug, Serialize, Deserialize, PartialEq)]
88#[serde(rename_all = "camelCase")]
89pub struct RegistrarTickResult {
90 pub phases: Vec<PhaseTickResult>,
91}
92
93async fn run_tick(
97 app_conf: web::Data<ApplicationConfiguration>,
98 pool: web::Data<PgPool>,
99 suotar_client: web::Data<SuotarClient>,
100 query: web::Query<RunTickQuery>,
101) -> ControllerResult<HttpResponse> {
102 super::assert_enabled(&app_conf);
103 let token = skip_authorize();
104
105 let Some(phase) = query
106 .phase
107 .as_deref()
108 .and_then(CreditRegistrationPhase::from_phase_name)
109 else {
110 return token.authorized_ok(HttpResponse::BadRequest().json(
111 PhaseTickResult::UnknownPhase {
112 phase: query.phase.clone(),
113 known_phases: known_phase_names(),
114 },
115 ));
116 };
117
118 let scope = match resolve_scope(&pool, &query).await? {
119 Ok(scope) => scope,
120 Err(unresolved) => {
122 return token.authorized_ok(HttpResponse::BadRequest().json(unresolved));
123 }
124 };
125
126 let ctx = tick_context(&app_conf, &pool, &suotar_client);
127 let result = PhaseTickResult::of(phase, run_phase_once(&ctx, phase, &scope).await?);
128 token.authorized_ok(match &result {
129 PhaseTickResult::Ran { .. } | PhaseTickResult::Skipped { .. } => {
130 HttpResponse::Ok().json(&result)
131 }
132 PhaseTickResult::ScopeNotSupported { .. } | PhaseTickResult::UnknownPhase { .. } => {
133 HttpResponse::BadRequest().json(&result)
134 }
135 })
136}
137
138async fn run_registrar_tick(
142 app_conf: web::Data<ApplicationConfiguration>,
143 pool: web::Data<PgPool>,
144 suotar_client: web::Data<SuotarClient>,
145) -> ControllerResult<HttpResponse> {
146 super::assert_enabled(&app_conf);
147 let token = skip_authorize();
148
149 let scope = PhaseScope::default();
150 let ctx = tick_context(&app_conf, &pool, &suotar_client);
151 let mut phases = Vec::new();
152 for phase in CreditRegistrationPhase::REGISTRAR_TICK_SEQUENCE {
153 phases.push(PhaseTickResult::of(
154 phase,
155 run_phase_once(&ctx, phase, &scope).await?,
156 ));
157 }
158 token.authorized_ok(HttpResponse::Ok().json(RegistrarTickResult { phases }))
159}
160
161#[derive(Debug, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct RegradeCompletionPayload {
164 pub credit_registration_id: Uuid,
165 pub grade: Option<i32>,
167 pub passed: Option<bool>,
169}
170
171#[derive(Debug, Serialize, Deserialize, PartialEq)]
172#[serde(rename_all = "camelCase")]
173pub struct RegradeCompletionResult {
174 pub course_module_completion_id: Uuid,
175 pub grade: Option<i32>,
176}
177
178async fn regrade_completion(
184 app_conf: web::Data<ApplicationConfiguration>,
185 pool: web::Data<PgPool>,
186 payload: web::Json<RegradeCompletionPayload>,
187) -> ControllerResult<HttpResponse> {
188 super::assert_enabled(&app_conf);
189 let token = skip_authorize();
190
191 let mut conn = pool.acquire().await?;
192 let registration =
193 models::credit_registrations::get_by_id(&mut conn, payload.credit_registration_id).await?;
194 models::course_module_completions::set_grade_for_testing(
195 &mut conn,
196 registration.course_module_completion_id,
197 payload.grade,
198 payload.passed,
199 )
200 .await?;
201 token.authorized_ok(HttpResponse::Ok().json(RegradeCompletionResult {
202 course_module_completion_id: registration.course_module_completion_id,
203 grade: payload.grade,
204 }))
205}
206
207#[derive(Debug, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct SetTestExclusiveHoldPayload {
210 pub user_email: String,
211 pub course_id: Option<Uuid>,
213 pub hold_secs: i64,
214}
215
216#[derive(Debug, Serialize, Deserialize, PartialEq)]
217#[serde(rename_all = "camelCase")]
218pub struct SetTestExclusiveHoldResult {
219 pub held_until: DateTime<Utc>,
220}
221
222const MAX_TEST_EXCLUSIVE_HOLD_SECS: i64 = 120;
226
227async fn set_test_exclusive_hold(
231 app_conf: web::Data<ApplicationConfiguration>,
232 pool: web::Data<PgPool>,
233 payload: web::Json<SetTestExclusiveHoldPayload>,
234) -> ControllerResult<HttpResponse> {
235 super::assert_enabled(&app_conf);
236 let token = skip_authorize();
237
238 if !(0..=MAX_TEST_EXCLUSIVE_HOLD_SECS).contains(&payload.hold_secs) {
239 return token.authorized_ok(HttpResponse::BadRequest().json(format!(
240 "holdSecs must be between 0 and {MAX_TEST_EXCLUSIVE_HOLD_SECS}"
241 )));
242 }
243
244 let mut conn = pool.acquire().await?;
245 let Some(user_id) = models::user_details::get_active_user_id_by_email_case_insensitive(
246 &mut conn,
247 &payload.user_email,
248 )
249 .await?
250 else {
251 return token.authorized_ok(HttpResponse::BadRequest().json(UnresolvedScope {
252 status: "unresolvedScope".to_string(),
253 half: "userEmail".to_string(),
254 value: payload.user_email.clone(),
255 }));
256 };
257
258 let held_until = Utc::now() + Duration::seconds(payload.hold_secs);
259 models::credit_registrations::set_test_exclusive_hold_for_testing(
260 &mut conn,
261 user_id,
262 payload.course_id,
263 held_until,
264 )
265 .await?;
266
267 token.authorized_ok(HttpResponse::Ok().json(SetTestExclusiveHoldResult { held_until }))
268}
269
270#[derive(Debug, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct QueuedEmailsQuery {
273 pub user_email: String,
274}
275
276#[derive(Debug, Serialize, Deserialize, PartialEq)]
277#[serde(rename_all = "camelCase")]
278pub struct QueuedEmail {
279 pub template_type: String,
280 pub placeholders: serde_json::Value,
281}
282
283const QUEUED_EMAIL_SCAN: i64 = 200;
287
288async fn queued_emails(
291 app_conf: web::Data<ApplicationConfiguration>,
292 pool: web::Data<PgPool>,
293 query: web::Query<QueuedEmailsQuery>,
294) -> ControllerResult<HttpResponse> {
295 super::assert_enabled(&app_conf);
296 let token = skip_authorize();
297
298 let mut conn = pool.acquire().await?;
299 let Some(user_id) = models::user_details::get_active_user_id_by_email_case_insensitive(
300 &mut conn,
301 &query.user_email,
302 )
303 .await?
304 else {
305 return token.authorized_ok(HttpResponse::BadRequest().json(UnresolvedScope {
306 status: "unresolvedScope".to_string(),
307 half: "userEmail".to_string(),
308 value: query.user_email.clone(),
309 }));
310 };
311 let queued = models::email_deliveries::get_recent_template_types_for_user_for_testing(
312 &mut conn,
313 user_id,
314 QUEUED_EMAIL_SCAN,
315 )
316 .await?
317 .into_iter()
318 .map(|(template_type, placeholders)| QueuedEmail {
319 template_type: serde_json::to_value(template_type)
320 .ok()
321 .and_then(|value| value.as_str().map(str::to_string))
322 .unwrap_or_default(),
323 placeholders,
324 })
325 .collect::<Vec<_>>();
326
327 token.authorized_ok(HttpResponse::Ok().json(queued))
328}
329
330fn tick_context<'a>(
332 app_conf: &'a ApplicationConfiguration,
333 pool: &'a PgPool,
334 suotar_client: &'a SuotarClient,
335) -> PhaseContext<'a> {
336 PhaseContext::from_app(pool, suotar_client, app_conf, "run-tick")
337}
338
339async fn resolve_scope(
341 pool: &PgPool,
342 query: &RunTickQuery,
343) -> anyhow::Result<Result<PhaseScope, UnresolvedScope>> {
344 let mut scope = PhaseScope {
345 course_id: query.course_id,
346 user_id: query.user_id,
347 credit_registration_ids: Vec::new(),
348 };
349 if let Some(slug) = &query.course_slug {
350 let mut conn = pool.acquire().await?;
351 let found = models::courses::get_active_course_id_by_slug(&mut conn, slug).await?;
352 match found {
353 Some(id) => scope.course_id = Some(id),
354 None => {
355 return Ok(Err(UnresolvedScope {
356 status: "unresolvedScope".to_string(),
357 half: "courseSlug".to_string(),
358 value: slug.clone(),
359 }));
360 }
361 }
362 }
363 if let Some(email) = &query.user_email {
364 let mut conn = pool.acquire().await?;
365 let found =
366 models::user_details::get_active_user_id_by_email_case_insensitive(&mut conn, email)
367 .await?;
368 match found {
369 Some(id) => scope.user_id = Some(id),
370 None => {
371 return Ok(Err(UnresolvedScope {
372 status: "unresolvedScope".to_string(),
373 half: "userEmail".to_string(),
374 value: email.clone(),
375 }));
376 }
377 }
378 }
379 if let Some(raw) = &query.credit_registration_ids {
380 for part in raw.split(',').filter(|part| !part.trim().is_empty()) {
381 match Uuid::parse_str(part.trim()) {
382 Ok(id) => scope.credit_registration_ids.push(id),
383 Err(_) => {
384 return Ok(Err(UnresolvedScope {
385 status: "unresolvedScope".to_string(),
386 half: "creditRegistrationIds".to_string(),
387 value: part.trim().to_string(),
388 }));
389 }
390 }
391 }
392 }
393 Ok(Ok(scope))
394}
395
396fn known_phase_names() -> Vec<String> {
397 CreditRegistrationPhase::ALL
398 .iter()
399 .map(|phase| phase.as_str().to_string())
400 .collect()
401}
402
403pub fn _add_routes(cfg: &mut ServiceConfig) {
404 cfg.route("/run-tick", web::post().to(run_tick))
405 .route("/run-registrar-tick", web::post().to(run_registrar_tick))
406 .route("/regrade-completion", web::post().to(regrade_completion))
407 .route(
408 "/test-exclusive-hold",
409 web::post().to(set_test_exclusive_hold),
410 )
411 .route("/queued-emails", web::get().to(queued_emails))
412 .configure(commands::_add_routes);
413}
414
415#[cfg(test)]
416mod tests {
417 use actix_web::{App, http::StatusCode, test, web::Data};
418
419 use super::*;
420 use crate::controllers::configure_controllers;
421
422 fn app_conf(test_suotar: bool) -> ApplicationConfiguration {
424 ApplicationConfiguration {
425 test_suotar,
426 ..ApplicationConfiguration::mock_conf()
427 .expect("the mock configuration is built from constants")
428 }
429 }
430
431 async fn call_run_tick(
434 test_suotar: bool,
435 query: &str,
436 ) -> actix_web::dev::ServiceResponse<actix_web::body::BoxBody> {
437 let app_conf = Data::new(app_conf(test_suotar));
438 let pool = Data::new(
439 PgPool::connect_lazy("postgres://headless-lms@localhost:54328/headless_lms_dev")
440 .expect("a lazy pool only parses the url"),
441 );
442 let service = test::init_service(
443 App::new()
444 .app_data(pool)
445 .app_data(Data::new(SuotarClient::mock_for_test()))
446 .app_data(app_conf.clone())
447 .service(
448 web::scope("/api/v0")
449 .configure(|cfg| configure_controllers(cfg, app_conf.clone())),
450 ),
451 )
452 .await;
453 let req = test::TestRequest::post()
454 .uri(&format!("/api/v0/mock-suotar/control/run-tick{query}"))
455 .to_request();
456 test::call_service(&service, req).await
457 }
458
459 #[actix_web::test]
461 async fn run_tick_is_absent_when_the_mock_is_disabled() {
462 let res = call_run_tick(false, "?phase=verify").await;
463 assert_eq!(res.status(), StatusCode::NOT_FOUND);
464 }
465
466 #[actix_web::test]
469 async fn a_scope_a_phase_cannot_apply_is_refused() {
470 let res = call_run_tick(
471 true,
472 &format!("?phase=retention-sweep&courseId={}", Uuid::new_v4()),
473 )
474 .await;
475 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
476 let body: PhaseTickResult = test::read_body_json(res).await;
477 assert_eq!(
478 body,
479 PhaseTickResult::ScopeNotSupported {
480 phase: "retention-sweep".to_string()
481 }
482 );
483 }
484
485 #[actix_web::test]
486 async fn an_invented_phase_name_is_rejected() {
487 let res = call_run_tick(true, "?phase=materialise").await;
488 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
489 let body: PhaseTickResult = test::read_body_json(res).await;
490 assert_eq!(
491 body,
492 PhaseTickResult::UnknownPhase {
493 phase: Some("materialise".to_string()),
494 known_phases: known_phase_names(),
495 }
496 );
497 }
498}