1use super::registration::CreditRegistration;
5use super::state::{
6 ADMIN_ONLY_TARGETS, CreditRegistrationErrorCode, CreditRegistrationState,
7 PendingSupersessionEffect,
8};
9use crate::credit_registration_events::{
10 CreditRegistrationEventKind, NewCreditRegistrationEvent, SuotarAnswer,
11};
12use crate::error::missing_model_error;
13use crate::library::credit_registration::timeline::TimelinePhase;
14use crate::prelude::*;
15use crate::suotar_api_calls::SuotarEndpoint;
16use chrono::TimeDelta;
17use std::collections::HashMap;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum AdminAttention {
22 Raise,
23 Clear,
24}
25
26impl AdminAttention {
27 pub fn is_raised(self) -> bool {
29 self == Self::Raise
30 }
31}
32
33#[derive(Debug, Clone, PartialEq)]
34pub struct Transition {
35 pub to_state: CreditRegistrationState,
36 pub error_code: Option<CreditRegistrationErrorCode>,
37 pub error_message: Option<String>,
39 pub needs_admin_attention: Option<AdminAttention>,
41 pub event_kind: CreditRegistrationEventKind,
42 pub event_message: Option<String>,
43 pub actor_user_id: Option<Uuid>,
44 pub suotar_api_call_id: Option<Uuid>,
45 pub suotar_endpoint: Option<SuotarEndpoint>,
46 pub suotar_requested_at: Option<DateTime<Utc>>,
47 pub suotar_answered_at: Option<DateTime<Utc>>,
48 pub suotar_answer: Option<SuotarAnswer>,
49 pub event_details: Option<serde_json::Value>,
51 pub request_item_id: Option<String>,
53 pub expected_from_state: Option<CreditRegistrationState>,
58 pub policy: TransitionPolicy,
60 pub next_attempt_at: Option<DateTime<Utc>>,
63 pub keeps_enrolment_checked_at: bool,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum TransitionPolicy {
71 Pipeline,
74 Admin,
79 Planted,
82}
83
84impl TransitionPolicy {
85 fn allows(self, from: CreditRegistrationState, to: CreditRegistrationState) -> bool {
86 match self {
87 Self::Pipeline => from.allowed_targets().contains(&to),
88 Self::Admin => from.allowed_targets().contains(&to) || ADMIN_ONLY_TARGETS.contains(&to),
89 Self::Planted => true,
90 }
91 }
92}
93
94impl Transition {
95 pub fn to(to_state: CreditRegistrationState) -> Self {
96 Self {
97 to_state,
98 error_code: None,
99 error_message: None,
100 needs_admin_attention: None,
101 event_kind: CreditRegistrationEventKind::StateChanged,
102 event_message: None,
103 actor_user_id: None,
104 suotar_api_call_id: None,
105 suotar_endpoint: None,
106 suotar_requested_at: None,
107 suotar_answered_at: None,
108 suotar_answer: None,
109 event_details: None,
110 request_item_id: None,
111 expected_from_state: None,
112 policy: TransitionPolicy::Pipeline,
113 next_attempt_at: None,
114 keeps_enrolment_checked_at: false,
115 }
116 }
117
118 pub fn by_hand(to_state: CreditRegistrationState) -> Self {
120 Self {
121 policy: TransitionPolicy::Admin,
122 ..Self::to(to_state)
123 }
124 }
125
126 pub fn planted(to_state: CreditRegistrationState) -> Self {
128 Self {
129 policy: TransitionPolicy::Planted,
130 ..Self::to(to_state)
131 }
132 }
133}
134
135pub async fn transition(
152 conn: &mut PgConnection,
153 id: Uuid,
154 transition: &Transition,
155) -> ModelResult<CreditRegistration> {
156 match transition_unless_moved_on(conn, id, transition).await? {
157 Transitioned::Written(after) => Ok(*after),
158 Transitioned::MovedOn { found } => Err(model_err!(
159 PreconditionFailed,
160 format!(
161 "Credit registration {id} is in {found:?}, not the expected {}: refusing to overwrite it.",
162 transition
163 .expected_from_state
164 .map(|expected| format!("{expected:?}"))
165 .unwrap_or_default()
166 )
167 )),
168 }
169}
170
171#[derive(Debug, Clone)]
173pub enum Transitioned {
174 Written(Box<CreditRegistration>),
176 MovedOn { found: CreditRegistrationState },
178}
179
180pub async fn transition_unless_moved_on(
184 conn: &mut PgConnection,
185 id: Uuid,
186 transition: &Transition,
187) -> ModelResult<Transitioned> {
188 let mut tx = conn.begin().await?;
189 let from_state = lock_for_moves(&mut tx, &[id])
190 .await?
191 .remove(&id)
192 .ok_or_else(missing_model_error(
193 ModelErrorType::RecordNotFound,
194 format!("Credit registration {id} does not exist."),
195 ))?;
196 if transition
197 .expected_from_state
198 .is_some_and(|expected| from_state != expected)
199 {
200 return Ok(Transitioned::MovedOn { found: from_state });
201 }
202 check_edge(id, from_state, transition.to_state, transition.policy)?;
203 let after = write_moves(&mut tx, &[(id, from_state, transition)])
204 .await?
205 .pop()
206 .ok_or_else(missing_model_error(
207 ModelErrorType::RecordNotFound,
208 format!("Credit registration {id} does not exist."),
209 ))?;
210 tx.commit().await?;
211 Ok(Transitioned::Written(Box::new(after)))
212}
213
214async fn lock_for_moves(
216 conn: &mut PgConnection,
217 ids: &[Uuid],
218) -> ModelResult<HashMap<Uuid, CreditRegistrationState>> {
219 let locked = sqlx::query!(
220 r#"
221SELECT id,
222 state
223FROM credit_registrations
224WHERE id = ANY($1)
225 AND deleted_at IS NULL
226ORDER BY id FOR
227UPDATE
228 "#,
229 ids
230 )
231 .fetch_all(conn)
232 .await?;
233 Ok(locked.into_iter().map(|row| (row.id, row.state)).collect())
234}
235
236async fn write_moves(
238 conn: &mut PgConnection,
239 moves: &[(Uuid, CreditRegistrationState, &Transition)],
240) -> ModelResult<Vec<CreditRegistration>> {
241 let events: Vec<NewCreditRegistrationEvent> = moves
242 .iter()
243 .map(|(id, from_state, transition)| NewCreditRegistrationEvent {
244 credit_registration_id: *id,
245 kind: transition.event_kind,
246 from_state: Some(*from_state),
247 to_state: Some(transition.to_state),
248 error_code: transition.error_code,
249 message: transition.event_message.clone(),
250 suotar_api_call_id: transition.suotar_api_call_id,
251 suotar_endpoint: transition.suotar_endpoint,
252 suotar_requested_at: transition.suotar_requested_at,
253 suotar_answered_at: transition.suotar_answered_at,
254 suotar_answer: transition.suotar_answer,
255 actor_user_id: transition.actor_user_id,
256 details: transition.event_details.clone(),
257 request_item_id: transition.request_item_id.clone(),
258 })
259 .collect();
260 let ids: Vec<Uuid> = moves.iter().map(|(id, _, _)| *id).collect();
261 let to_states: Vec<CreditRegistrationState> = moves
262 .iter()
263 .map(|(_, _, transition)| transition.to_state)
264 .collect();
265 let error_codes: Vec<Option<CreditRegistrationErrorCode>> = moves
266 .iter()
267 .map(|(_, _, transition)| transition.error_code)
268 .collect();
269 let error_messages: Vec<Option<String>> = moves
270 .iter()
271 .map(|(_, _, transition)| transition.error_message.clone())
272 .collect();
273 let needs_admin: Vec<Option<bool>> = moves
274 .iter()
275 .map(|(_, _, transition)| {
276 transition
277 .needs_admin_attention
278 .map(AdminAttention::is_raised)
279 })
280 .collect();
281 let terminal: Vec<bool> = to_states.iter().map(|state| state.is_terminal()).collect();
282 let failure: Vec<bool> = to_states
283 .iter()
284 .map(|state| state.is_failed_state())
285 .collect();
286 let next_attempts: Vec<Option<DateTime<Utc>>> = moves
287 .iter()
288 .map(|(_, _, transition)| transition.next_attempt_at)
289 .collect();
290 let default_delays: Vec<TimeDelta> = to_states
291 .iter()
292 .map(|state| state.default_attempt_delay())
293 .collect();
294 let keeps_checked_at: Vec<bool> = moves
295 .iter()
296 .map(|(_, _, transition)| transition.keeps_enrolment_checked_at)
297 .collect();
298 let keeps_schedule: Vec<bool> = to_states
299 .iter()
300 .map(|state| state.keeps_enrolment_check_schedule())
301 .collect();
302 let enters_another_phase: Vec<bool> = moves
303 .iter()
304 .map(|(_, from_state, transition)| {
305 TimelinePhase::entered_with(*from_state)
306 != TimelinePhase::entered_with(transition.to_state)
307 })
308 .collect();
309 let keeps_waiting_since: Vec<bool> = to_states
310 .iter()
311 .map(|state| {
312 state.keeps_enrolment_check_schedule()
313 && *state != CreditRegistrationState::NoUsableEnrolment
314 })
315 .collect();
316 let written = sqlx::query_as!(
317 CreditRegistration,
318 r#"
319UPDATE credit_registrations cr
320SET state = move.to_state,
321 -- clock_timestamp(), not now(): now() is the transaction timestamp, so several state changes in
322 -- one transaction would share an instant and the timeline would lose their order.
323 state_entered_at = clock_timestamp(),
324 state_changed_at = CASE
325 WHEN cr.state <> move.to_state THEN clock_timestamp()
326 ELSE cr.state_changed_at
327 END,
328 phase_started_at = CASE
329 WHEN move.enters_another_phase THEN clock_timestamp()
330 ELSE cr.phase_started_at
331 END,
332 error_code = move.error_code,
333 error_message = move.error_message,
334 needs_admin_attention = COALESCE(move.needs_admin_attention, cr.needs_admin_attention),
335 -- ELSE NULL: without it an admin retry stays invisible to every terminal_at IS NULL query.
336 terminal_at = CASE
337 WHEN move.terminal THEN COALESCE(cr.terminal_at, now())
338 ELSE NULL
339 END,
340 first_failed_at = CASE
341 WHEN move.failure THEN COALESCE(cr.first_failed_at, now())
342 WHEN move.to_state = 'no_usable_enrolment' THEN NULL
343 ELSE cr.first_failed_at
344 END,
345 submit_retry_count = CASE
346 WHEN move.to_state = 'no_usable_enrolment' THEN 0
347 ELSE cr.submit_retry_count
348 END,
349 registered_at = CASE
350 WHEN move.to_state = 'registered' THEN COALESCE(cr.registered_at, now())
351 ELSE cr.registered_at
352 END,
353 submitted_at = CASE
354 WHEN move.to_state = 'submitting' THEN now()
355 ELSE cr.submitted_at
356 END,
357 enrolment_checked_at = CASE
358 WHEN move.keeps_checked_at THEN cr.enrolment_checked_at
359 WHEN (
360 cr.state = 'resolving_enrolment'
361 OR cr.enrolment_check_claimed_until IS NOT NULL
362 )
363 AND move.to_state IN ('checking_enrolment', 'no_usable_enrolment') THEN now()
364 WHEN cr.state = 'checking_enrolment'
365 AND move.to_state <> 'checking_enrolment' THEN now()
366 ELSE cr.enrolment_checked_at
367 END,
368 -- Only on starting to wait, not on every check that finds no enrolment again.
369 enrolment_banner_dismissed_at = CASE
370 WHEN move.to_state = 'no_usable_enrolment'
371 AND cr.no_usable_enrolment_since IS NULL THEN NULL
372 ELSE cr.enrolment_banner_dismissed_at
373 END,
374 -- A retried lookup passes through the states that keep it on its way back to no_usable_enrolment.
375 no_usable_enrolment_since = CASE
376 WHEN move.to_state = 'no_usable_enrolment' THEN COALESCE(cr.no_usable_enrolment_since, now())
377 WHEN move.keeps_waiting_since THEN cr.no_usable_enrolment_since
378 ELSE NULL
379 END,
380 enrolment_check_anchor_at = CASE
381 WHEN move.keeps_schedule THEN cr.enrolment_check_anchor_at
382 END,
383 enrolment_check_step = CASE
384 WHEN move.keeps_schedule THEN cr.enrolment_check_step
385 END,
386 enrolment_check_due_at = CASE
387 WHEN move.keeps_schedule THEN cr.enrolment_check_due_at
388 END,
389 is_enrolment_check_batched = move.keeps_schedule
390 AND cr.is_enrolment_check_batched,
391 enrolment_checks_stopped_at = CASE
392 WHEN move.keeps_schedule THEN cr.enrolment_checks_stopped_at
393 END,
394 enrolment_check_source = CASE
395 WHEN move.keeps_schedule THEN cr.enrolment_check_source
396 ELSE 'schedule'
397 END,
398 enrolment_check_claimed_until = NULL,
399 next_attempt_at = COALESCE(
400 move.next_attempt_at,
401 now() + move.default_delay
402 )
403FROM UNNEST(
404 $1::uuid [],
405 $2::credit_registration_state [],
406 $3::credit_registration_error_code [],
407 $4::text [],
408 $5::boolean [],
409 $6::boolean [],
410 $7::boolean [],
411 $8::timestamptz [],
412 $9::interval [],
413 $10::boolean [],
414 $11::boolean [],
415 $12::boolean [],
416 $13::boolean []
417 ) AS move(
418 id,
419 to_state,
420 error_code,
421 error_message,
422 needs_admin_attention,
423 terminal,
424 failure,
425 next_attempt_at,
426 default_delay,
427 keeps_checked_at,
428 keeps_schedule,
429 keeps_waiting_since,
430 enters_another_phase
431 )
432WHERE cr.id = move.id
433 AND cr.deleted_at IS NULL
434RETURNING cr.*
435 "#,
436 &ids,
437 &to_states as &[CreditRegistrationState],
438 &error_codes as &[Option<CreditRegistrationErrorCode>],
439 &error_messages as &[Option<String>],
440 &needs_admin as &[Option<bool>],
441 &terminal,
442 &failure,
443 &next_attempts as &[Option<DateTime<Utc>>],
444 &default_delays as &[TimeDelta],
445 &keeps_checked_at,
446 &keeps_schedule,
447 &keeps_waiting_since,
448 &enters_another_phase,
449 )
450 .fetch_all(&mut *conn)
451 .await?;
452
453 let settled: Vec<_> = ids.iter().copied().zip(to_states.iter().copied()).collect();
454 settle_pending_supersessions(&mut *conn, &settled).await?;
455 crate::credit_registration_events::insert_batch(conn, &events).await?;
456 Ok(written)
457}
458
459async fn settle_pending_supersessions(
462 conn: &mut PgConnection,
463 moves: &[(Uuid, CreditRegistrationState)],
464) -> ModelResult<()> {
465 let mut completed = Vec::new();
466 let mut abandoned = Vec::new();
467 for &(id, to_state) in moves {
468 match to_state.pending_supersession_effect() {
469 PendingSupersessionEffect::Keep => {}
470 PendingSupersessionEffect::Complete => completed.push(id),
471 PendingSupersessionEffect::Abandon => abandoned.push(id),
472 }
473 }
474 if completed.is_empty() && abandoned.is_empty() {
475 return Ok(());
476 }
477 sqlx::query!(
478 r#"
479UPDATE credit_registrations
480SET superseded_by_id = CASE
481 WHEN pending_superseded_by_id = ANY($1::uuid []) THEN pending_superseded_by_id
482 ELSE superseded_by_id
483 END,
484 superseded_at = CASE
485 WHEN pending_superseded_by_id = ANY($1::uuid []) THEN now()
486 ELSE superseded_at
487 END,
488 pending_superseded_by_id = NULL
489WHERE (
490 pending_superseded_by_id = ANY($1::uuid [])
491 OR pending_superseded_by_id = ANY($2::uuid [])
492 )
493 AND deleted_at IS NULL
494 "#,
495 &completed,
496 &abandoned,
497 )
498 .execute(conn)
499 .await?;
500 Ok(())
501}
502
503fn check_edge(
506 id: Uuid,
507 from: CreditRegistrationState,
508 to: CreditRegistrationState,
509 policy: TransitionPolicy,
510) -> ModelResult<()> {
511 if from == to || policy.allows(from, to) {
513 return Ok(());
514 }
515 Err(model_err!(
516 InvalidRequest,
517 format!("Credit registration {id} may not move from {from:?} to {to:?} under {policy:?}.")
518 ))
519}
520
521#[derive(Debug, Clone, PartialEq)]
523pub struct BatchMove {
524 pub id: Uuid,
525 pub transition: Transition,
526}
527
528pub async fn transition_batch(conn: &mut PgConnection, moves: &[BatchMove]) -> ModelResult<i64> {
535 if moves.is_empty() {
536 return Ok(0);
537 }
538 let mut tx = conn.begin().await?;
539 let ids: Vec<Uuid> = moves.iter().map(|batch_move| batch_move.id).collect();
540 let locked = lock_for_moves(&mut tx, &ids).await?;
541 let mut writes = Vec::new();
542 for batch_move in moves {
543 let Some(&from) = locked.get(&batch_move.id) else {
544 continue;
545 };
546 if batch_move
547 .transition
548 .expected_from_state
549 .is_some_and(|expected| expected != from)
550 {
551 continue;
552 }
553 check_edge(
554 batch_move.id,
555 from,
556 batch_move.transition.to_state,
557 batch_move.transition.policy,
558 )?;
559 writes.push((batch_move.id, from, &batch_move.transition));
560 }
561 if !writes.is_empty() {
562 write_moves(&mut tx, &writes).await?;
563 }
564 tx.commit().await?;
565 Ok(i64::try_from(writes.len()).unwrap_or(i64::MAX))
566}
567
568const UNSENT_STATES: [CreditRegistrationState; 7] = [
571 CreditRegistrationState::Pending,
572 CreditRegistrationState::ReadyToSubmit,
573 CreditRegistrationState::ResolvingEnrolment,
574 CreditRegistrationState::CheckingEnrolment,
575 CreditRegistrationState::NoUsableEnrolment,
576 CreditRegistrationState::Blocked,
577 CreditRegistrationState::FailedRetryable,
578];
579
580pub async fn cancel_unsent_for_course_module(
584 conn: &mut PgConnection,
585 course_module_id: Uuid,
586 actor_user_id: Uuid,
587) -> ModelResult<i64> {
588 let rows = sqlx::query!(
589 r#"
590SELECT id,
591 state
592FROM credit_registrations
593WHERE course_module_id = $1
594 AND state = ANY($2::credit_registration_state [])
595 AND submitted_attainment_id IS NULL
596 AND superseded_by_id IS NULL
597 AND deleted_at IS NULL
598 "#,
599 course_module_id,
600 &UNSENT_STATES as &[CreditRegistrationState],
601 )
602 .fetch_all(&mut *conn)
603 .await?;
604 let moves: Vec<BatchMove> = rows
605 .into_iter()
606 .map(|row| BatchMove {
607 id: row.id,
608 transition: Transition {
609 needs_admin_attention: Some(AdminAttention::Clear),
610 event_message: Some(
611 "Completions of this module are no longer registered to Sisu.".to_string(),
612 ),
613 actor_user_id: Some(actor_user_id),
614 expected_from_state: Some(row.state),
616 ..Transition::to(CreditRegistrationState::Cancelled)
617 },
618 })
619 .collect();
620 transition_batch(conn, &moves).await
621}