Skip to main content

headless_lms_models/
credit_registrations.rs

1//! The credit registration ledger.
2//!
3//! [`transition`], and its batched twin [`transition_batch`], are the only writers of `state`,
4//! stamping `state_entered_at`, the lifecycle timestamps and the audit event in one transaction.
5//! Which transition to make is the caller's decision; whether it is one the machine has is decided
6//! here, from [`CreditRegistrationState::allowed_targets`].
7use std::collections::HashMap;
8
9use chrono::NaiveDate;
10use utoipa::ToSchema;
11
12use crate::credit_registration_events::{CreditRegistrationEventKind, NewCreditRegistrationEvent};
13use crate::library::credit_registration::{
14    CreditRegistrationPendingReason, PendingPreconditions, PendingReasonCounts,
15};
16use crate::library::students_view::escape_like_pattern;
17use crate::prelude::*;
18use crate::verified_student_numbers::StudentNumberVerificationMethod;
19
20/// What the pipeline does next with a ledger row.
21#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
22#[sqlx(type_name = "credit_registration_state", rename_all = "snake_case")]
23#[serde(rename_all = "snake_case")]
24pub enum CreditRegistrationState {
25    /// Waiting on a precondition: the completion or a linked student number. Which one is derived
26    /// at read time, never stored.
27    Pending,
28    ReadyToSubmit,
29    ResolvingEnrolment,
30    CheckingEnrolment,
31    NoUsableEnrolment,
32    Submitting,
33    SubmissionUncertain,
34    AwaitingVerification,
35    Registered,
36    Duplicate,
37    NotImproved,
38    Misregistered,
39    FailedRetryable,
40    FailedPermanent,
41    Blocked,
42    Cancelled,
43}
44
45impl CreditRegistrationState {
46    /// Every state, so a classification can be proven exhaustive at runtime too.
47    pub const ALL: [Self; 16] = [
48        Self::Pending,
49        Self::ReadyToSubmit,
50        Self::ResolvingEnrolment,
51        Self::CheckingEnrolment,
52        Self::NoUsableEnrolment,
53        Self::Submitting,
54        Self::SubmissionUncertain,
55        Self::AwaitingVerification,
56        Self::Registered,
57        Self::Duplicate,
58        Self::NotImproved,
59        Self::Misregistered,
60        Self::FailedRetryable,
61        Self::FailedPermanent,
62        Self::Blocked,
63        Self::Cancelled,
64    ];
65
66    /// States the pipeline never leaves on its own. `terminal_at` tracks membership, cleared on
67    /// exit so an admin retry becomes visible to the stuck queries again.
68    pub fn is_terminal(self) -> bool {
69        matches!(
70            self,
71            Self::Registered
72                | Self::Duplicate
73                | Self::NotImproved
74                | Self::FailedPermanent
75                | Self::Cancelled
76        )
77    }
78
79    /// Entry to one of these anchors the retry window in `first_failed_at`.
80    pub fn is_failure(self) -> bool {
81        matches!(self, Self::FailedRetryable | Self::FailedPermanent)
82    }
83
84    /// Used for reporting and for the double-registration guard.
85    pub fn is_success(self) -> bool {
86        matches!(self, Self::Registered | Self::Duplicate | Self::NotImproved)
87    }
88
89    /// [`Self::is_success`]'s states, for binding as `= ANY($n::credit_registration_state[])` in
90    /// queries that would otherwise hand-retype the same set as a SQL literal. Order-independent;
91    /// kept in `is_success`'s own order for readability.
92    pub const SUCCESS_STATES: [Self; 3] = [Self::Registered, Self::Duplicate, Self::NotImproved];
93
94    /// [`Self::SUCCESS_STATES`] minus `Registered`: the credit exists but we did not put it there.
95    pub const OTHER_SUCCESS_STATES: [Self; 2] = [Self::Duplicate, Self::NotImproved];
96
97    /// The two states a "failed" count means across the admin reports: a permanent submit failure
98    /// and a reversal the study registry made after the fact.
99    pub const HARD_FAILURE_STATES: [Self; 2] = [Self::FailedPermanent, Self::Misregistered];
100
101    /// The states the pipeline itself may move a row from `self` to, staying put excluded.
102    ///
103    /// The one place the shape of the machine is written down: every edge here is one a phase, the
104    /// precondition recompute or the grade-improvement materialiser actually takes, and
105    /// [`transition`] refuses anything else. A hand transition also gets [`ADMIN_ONLY_TARGETS`],
106    /// which is why they are not in here: an edge only a human may take must stay out of reach of a
107    /// phase that gets its target wrong.
108    pub fn allowed_targets(self) -> &'static [Self] {
109        use CreditRegistrationState as S;
110        match self {
111            // The way out of the wait is every precondition being met; the other two edges are
112            // eligibility or the completion going away.
113            S::Pending => &[S::ReadyToSubmit, S::Blocked, S::Cancelled],
114            // `resolving_enrolment` is resolve-enrolments claiming the row and `failed_retryable`
115            // is it finding nothing to ask about; the rest is that phase's preflight and the
116            // preconditions.
117            S::ReadyToSubmit => &[
118                S::Pending,
119                S::ResolvingEnrolment,
120                S::FailedRetryable,
121                S::FailedPermanent,
122                S::Blocked,
123                S::Cancelled,
124            ],
125            // No `ready_to_submit`: a resolve call is out, and only that phase's own commit may
126            // move the row, or import could claim it before the enrolment is resolved.
127            S::ResolvingEnrolment => &[
128                S::Pending,
129                S::CheckingEnrolment,
130                S::NoUsableEnrolment,
131                S::Duplicate,
132                S::FailedRetryable,
133                S::FailedPermanent,
134                S::Blocked,
135                S::Cancelled,
136            ],
137            // `submitting` is import's, and the only edge into it.
138            S::CheckingEnrolment => &[
139                S::Pending,
140                S::ReadyToSubmit,
141                S::Submitting,
142                S::Duplicate,
143                S::FailedPermanent,
144                S::Blocked,
145                S::Cancelled,
146            ],
147            S::NoUsableEnrolment => &[S::Pending, S::ReadyToSubmit, S::Blocked, S::Cancelled],
148            // A request is in flight: every edge out is an answer to it. Nothing leads back to a
149            // state import claims.
150            S::Submitting => &[
151                S::Pending,
152                S::NoUsableEnrolment,
153                S::AwaitingVerification,
154                S::SubmissionUncertain,
155                S::Registered,
156                S::Duplicate,
157                S::NotImproved,
158                S::FailedRetryable,
159                S::FailedPermanent,
160            ],
161            // Both poller states: verify is the only path to `registered`, and neither may reach a
162            // state that leads back to import.
163            S::AwaitingVerification | S::SubmissionUncertain => {
164                &[S::Registered, S::Duplicate, S::Misregistered]
165            }
166            // The backoff elapsing resumes the row at whichever state matches how far it had got.
167            S::FailedRetryable => &[
168                S::Pending,
169                S::ReadyToSubmit,
170                S::CheckingEnrolment,
171                S::AwaitingVerification,
172                S::FailedPermanent,
173                S::Blocked,
174                S::Cancelled,
175            ],
176            S::Blocked => &[S::Pending, S::ReadyToSubmit, S::Cancelled],
177            // Terminal, and `misregistered` waits for a human: the pipeline leaves all of these
178            // where they are.
179            S::Registered
180            | S::Duplicate
181            | S::NotImproved
182            | S::Misregistered
183            | S::FailedPermanent
184            | S::Cancelled => &[],
185        }
186    }
187
188    /// Whether a row in `self` may move back to `ready_to_submit`, and why not if it may not.
189    ///
190    /// One precedence shared by the teacher-facing retry and the admin ledger's hand transitions,
191    /// which otherwise refuse the same rows for the same reasons in three independently maintained
192    /// copies. `strictness` is the one real difference between the callers: how far outside a
193    /// failure a row may still be moved from. Superseded is checked first regardless, since acting
194    /// on a replaced attempt is never right, and an outcome the registry already holds next, since
195    /// no strictness may resubmit over one.
196    pub fn resubmission_refusal(
197        self,
198        superseded: bool,
199        strictness: ResubmissionStrictness,
200    ) -> Option<ResubmissionRefusal> {
201        if superseded {
202            return Some(ResubmissionRefusal::Superseded);
203        }
204        if self.is_success() {
205            return Some(ResubmissionRefusal::AlreadySucceeded);
206        }
207        if strictness != ResubmissionStrictness::Any && self == Self::SubmissionUncertain {
208            return Some(ResubmissionRefusal::SubmissionUncertain);
209        }
210        if strictness == ResubmissionStrictness::OnlyFailedPermanent
211            && self != Self::FailedPermanent
212        {
213            return Some(ResubmissionRefusal::NotFailedPermanent);
214        }
215        None
216    }
217
218    /// Why a hand transition of this row to `target` is refused, or `None` if it may go ahead.
219    ///
220    /// The safety half of the admin path, next to the structural half in [`ADMIN_ONLY_TARGETS`]:
221    /// the edge table says the move exists, this says whether this row may take it. A row whose
222    /// outcome the study registry already holds is refused whatever the target, because `cancelled`
223    /// is a legal step on to `ready_to_submit` and would otherwise launder a second submission for
224    /// a credit Sisu has. `strictness` is how the caller treats `submission_uncertain`.
225    pub fn admin_transition_refusal(
226        self,
227        target: Self,
228        superseded: bool,
229        strictness: ResubmissionStrictness,
230    ) -> Option<ResubmissionRefusal> {
231        if superseded {
232            return Some(ResubmissionRefusal::Superseded);
233        }
234        if self.is_success() {
235            return Some(ResubmissionRefusal::AlreadySucceeded);
236        }
237        if target != Self::ReadyToSubmit {
238            return None;
239        }
240        self.resubmission_refusal(false, strictness)
241    }
242
243    /// How long a row entering this state waits before the pipeline may claim it again, when the
244    /// caller of [`transition`] names no time of its own. Zero leaves it claimable at once.
245    ///
246    /// Only the states a claim query reads, or a precondition arm holds a row in, need a nonzero
247    /// one: a phase that forgot to defer would otherwise spin on the row, since `claim_due` orders
248    /// by `next_attempt_at`. A caller with a real backoff to apply passes it and overrides this.
249    fn default_attempt_delay_secs(self) -> i64 {
250        use crate::library::credit_registration::backoff::{
251            NO_USABLE_ENROLMENT_RECHECK_SECS, SUBMIT_BASE_BACKOFF_SECS, UNCERTAIN_RECHECK_SECS,
252            VERIFY_FIRST_DELAY_SECS,
253        };
254        match self {
255            Self::AwaitingVerification => VERIFY_FIRST_DELAY_SECS,
256            Self::SubmissionUncertain => UNCERTAIN_RECHECK_SECS,
257            Self::NoUsableEnrolment => NO_USABLE_ENROLMENT_RECHECK_SECS,
258            Self::FailedRetryable => SUBMIT_BASE_BACKOFF_SECS,
259            _ => 0,
260        }
261    }
262}
263
264/// The edges only a hand transition may take, from any state [`admin_transition_refusal`] does not
265/// refuse: putting a row back on the pipeline, and writing one off.
266///
267/// Kept out of [`CreditRegistrationState::allowed_targets`] so no phase can take one by mistake.
268pub const ADMIN_ONLY_TARGETS: [CreditRegistrationState; 2] = [
269    CreditRegistrationState::ReadyToSubmit,
270    CreditRegistrationState::Cancelled,
271];
272
273/// How far outside a failure [`CreditRegistrationState::resubmission_refusal`] will still allow a
274/// row to move back to `ready_to_submit`.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum ResubmissionStrictness {
277    /// The automatic teacher retry: only a row that failed for good may go back on the pipeline,
278    /// because a row this always refuses would otherwise occupy a slot of the bulk cap forever.
279    OnlyFailedPermanent,
280    /// An admin's bulk hand transition: any state may move, except `submission_uncertain`, which
281    /// re-importing could put a second attainment on a real transcript over, so it needs a human
282    /// looking at that one row rather than a checkbox in a list.
283    AnyExceptSubmissionUncertain,
284    /// An admin's single-row hand transition: a human is already looking at this one row, so even
285    /// `submission_uncertain` may be resubmitted.
286    Any,
287}
288
289/// Why [`CreditRegistrationState::resubmission_refusal`] would not move a row.
290///
291/// Rendered by the teacher and admin surfaces, which decide from it which buttons a row gets, so it
292/// travels to them as it is rather than being re-mapped per surface.
293#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
294#[serde(rename_all = "snake_case")]
295pub enum ResubmissionRefusal {
296    /// A later attempt replaced this one; act on that.
297    Superseded,
298    /// The study registry already holds an outcome for this attempt, so there is nothing to submit
299    /// again.
300    AlreadySucceeded,
301    /// The submission may have landed, so only a human looking at this one row may move it.
302    SubmissionUncertain,
303    /// Not a failure at all: [`ResubmissionStrictness::OnlyFailedPermanent`] only.
304    NotFailedPermanent,
305}
306
307/// Why a ledger row is where it is; `state` says what happens to it next.
308#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
309#[sqlx(
310    type_name = "credit_registration_error_code",
311    rename_all = "snake_case"
312)]
313#[serde(rename_all = "snake_case")]
314pub enum CreditRegistrationErrorCode {
315    PersonNotFound,
316    CourseCodeNotFound,
317    EnrolmentNotFound,
318    EnrolmentNotAccepted,
319    InvalidGradeForGradeScale,
320    CourseNotAllowed,
321    InvalidCredits,
322    StudyRightNotValid,
323    AcceptorNotFound,
324    SisuValidationFailed,
325    SisuTimeout,
326    SisuTemporarilyUnavailable,
327    Misregistered,
328    Unauthorized,
329    MalformedRequest,
330    TransportError,
331    UnexpectedResponse,
332    NoGradeScaleMapping,
333    MissingUhCourseCode,
334    MissingEctsCredits,
335    RetryWindowExpired,
336    Unknown,
337}
338
339impl CreditRegistrationErrorCode {
340    /// Every code, so the retryability classification can be proven total at runtime too.
341    pub const ALL: [Self; 22] = [
342        Self::PersonNotFound,
343        Self::CourseCodeNotFound,
344        Self::EnrolmentNotFound,
345        Self::EnrolmentNotAccepted,
346        Self::InvalidGradeForGradeScale,
347        Self::CourseNotAllowed,
348        Self::InvalidCredits,
349        Self::StudyRightNotValid,
350        Self::AcceptorNotFound,
351        Self::SisuValidationFailed,
352        Self::SisuTimeout,
353        Self::SisuTemporarilyUnavailable,
354        Self::Misregistered,
355        Self::Unauthorized,
356        Self::MalformedRequest,
357        Self::TransportError,
358        Self::UnexpectedResponse,
359        Self::NoGradeScaleMapping,
360        Self::MissingUhCourseCode,
361        Self::MissingEctsCredits,
362        Self::RetryWindowExpired,
363        Self::Unknown,
364    ];
365}
366
367#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
368pub struct CreditRegistration {
369    pub id: Uuid,
370    pub created_at: DateTime<Utc>,
371    pub updated_at: DateTime<Utc>,
372    pub deleted_at: Option<DateTime<Utc>>,
373    pub course_module_completion_id: Uuid,
374    pub user_id: Uuid,
375    pub course_id: Uuid,
376    pub course_module_id: Uuid,
377    pub course_instance_id: Uuid,
378    pub state: CreditRegistrationState,
379    pub state_entered_at: DateTime<Utc>,
380    pub error_code: Option<CreditRegistrationErrorCode>,
381    pub error_message: Option<String>,
382    pub needs_admin_attention: bool,
383    pub enrolment_banner_dismissed_at: Option<DateTime<Utc>>,
384    pub student_number: Option<String>,
385    pub sisu_person_id: Option<String>,
386    pub uh_course_code: Option<String>,
387    pub selected_enrolment_id: Option<String>,
388    pub selected_enrolment_kind: Option<String>,
389    pub selected_enrolment_realisation_id: Option<String>,
390    pub attainment_date: Option<NaiveDate>,
391    pub attainment_language: Option<String>,
392    pub grade_scale_id: Option<String>,
393    pub grade_id: Option<String>,
394    pub credits: Option<f32>,
395    pub request_item_id: String,
396    pub submitted_attainment_id: Option<String>,
397    pub submitted_attainment_type: Option<String>,
398    pub sisu_attainment_id: Option<String>,
399    pub sisu_attainment_type: Option<String>,
400    pub submit_retry_count: i32,
401    pub verify_attempt_count: i32,
402    pub next_attempt_at: DateTime<Utc>,
403    pub first_failed_at: Option<DateTime<Utc>>,
404    pub last_attempt_at: Option<DateTime<Utc>>,
405    pub attempt_number: i32,
406    pub superseded_by_id: Option<Uuid>,
407    pub superseded_at: Option<DateTime<Utc>>,
408    pub enrolment_checked_at: Option<DateTime<Utc>>,
409    pub submitted_at: Option<DateTime<Utc>>,
410    pub registered_at: Option<DateTime<Utc>>,
411    pub terminal_at: Option<DateTime<Utc>>,
412    /// Set once the student mail for that outcome is queued, and never cleared: these two are the
413    /// idempotency guard for the `student-notifications` phase.
414    pub action_needed_email_delivery_id: Option<Uuid>,
415    pub registered_email_delivery_id: Option<Uuid>,
416    /// The completion revision the grade-improvement scan last found no improvement against. See
417    /// [`mark_improvement_checked`].
418    pub improvement_checked_completion_updated_at: Option<DateTime<Utc>>,
419}
420
421#[derive(Debug, Clone, PartialEq)]
422pub struct NewCreditRegistration {
423    pub course_module_completion_id: Uuid,
424    pub user_id: Uuid,
425    pub course_id: Uuid,
426    pub course_module_id: Uuid,
427    pub course_instance_id: Uuid,
428    pub attempt_number: i32,
429}
430
431/// The item id Suotar sees for the import and resolve calls. Deterministic, so a Suotar log line
432/// maps to one ledger row without an id allocation table.
433pub fn import_request_item_id(registration_id: Uuid) -> String {
434    format!("cr-{registration_id}")
435}
436
437/// The item id Suotar sees for one verify poll.
438pub fn verify_request_item_id(registration_id: Uuid, verify_attempt_count: i32) -> String {
439    format!("vf-{registration_id}-{verify_attempt_count}")
440}
441
442/// The item id Suotar sees for one look through a student's attainments for a submission we lost
443/// track of.
444pub fn recovery_request_item_id(registration_id: Uuid, verify_attempt_count: i32) -> String {
445    format!("rc-{registration_id}-{verify_attempt_count}")
446}
447
448/// Which call a request item id addresses a row for.
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum RequestPurpose {
451    /// Carrying the row's payload forward: `resolve-enrolments` and then `import`. The row's own
452    /// stored id, unchanged across retries, because it is the handle Suotar's log and ours share on
453    /// one registration.
454    Submission,
455    VerifyPoll(i32),
456    /// A recovery lookup, which goes to `resolve-enrolments` too: under the submission id it would
457    /// be indistinguishable from the row's own resolve call in the registry's log.
458    UncertainRecovery(i32),
459}
460
461/// What one registration is called in a request. The one place a sender picks an item id, so two
462/// calls about one row stay tellable apart in both logs.
463pub fn request_item_id(row: &CreditRegistration, purpose: RequestPurpose) -> String {
464    match purpose {
465        RequestPurpose::Submission => row.request_item_id.clone(),
466        RequestPurpose::VerifyPoll(attempt) => verify_request_item_id(row.id, attempt),
467        RequestPurpose::UncertainRecovery(attempt) => recovery_request_item_id(row.id, attempt),
468    }
469}
470
471/// Creates a ledger row at `pending` with a `created` event. The id is allocated here
472/// because `request_item_id` derives from it.
473pub async fn insert(
474    conn: &mut PgConnection,
475    pkey_policy: PKeyPolicy<Uuid>,
476    new: &NewCreditRegistration,
477    event_message: Option<&str>,
478) -> ModelResult<Uuid> {
479    let id = pkey_policy.into_uuid();
480    let mut tx = conn.begin().await?;
481    sqlx::query!(
482        r#"
483INSERT INTO credit_registrations (
484    id,
485    course_module_completion_id,
486    user_id,
487    course_id,
488    course_module_id,
489    course_instance_id,
490    attempt_number,
491    request_item_id
492  )
493VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
494        "#,
495        id,
496        new.course_module_completion_id,
497        new.user_id,
498        new.course_id,
499        new.course_module_id,
500        new.course_instance_id,
501        new.attempt_number,
502        import_request_item_id(id),
503    )
504    .execute(&mut *tx)
505    .await?;
506
507    crate::credit_registration_events::insert(
508        &mut tx,
509        &NewCreditRegistrationEvent {
510            message: event_message.map(str::to_string),
511            ..NewCreditRegistrationEvent::new(id, CreditRegistrationEventKind::Created)
512        },
513    )
514    .await?;
515
516    tx.commit().await?;
517    Ok(id)
518}
519
520#[derive(Debug, Clone, PartialEq)]
521pub struct Transition {
522    pub to_state: CreditRegistrationState,
523    pub error_code: Option<CreditRegistrationErrorCode>,
524    /// Scrub before passing: this is persisted.
525    pub error_message: Option<String>,
526    pub needs_admin_attention: Option<bool>,
527    pub event_kind: CreditRegistrationEventKind,
528    pub event_message: Option<String>,
529    pub actor_user_id: Option<Uuid>,
530    pub suotar_api_call_id: Option<Uuid>,
531    /// Already scrubbed `{request, response}` payload for the event row.
532    pub event_details: Option<serde_json::Value>,
533    /// Set by a caller that computed `to_state` from a row snapshot taken before an `await` (an
534    /// external call, or a gap before its own transaction) during which some other writer could
535    /// have moved the row on. `None` skips the check, for callers writing from a snapshot taken
536    /// under the same transaction's lock.
537    pub expected_from_state: Option<CreditRegistrationState>,
538    /// Which (from → to) edges this write may take; see [`TransitionPolicy`].
539    pub policy: TransitionPolicy,
540    /// When the pipeline may claim the row next. `None` takes the target state's default cadence,
541    /// which is what keeps a caller that forgets from leaving the row spinning.
542    pub next_attempt_at: Option<DateTime<Utc>>,
543}
544
545/// Which (from → to) edges [`transition`] will write.
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
547pub enum TransitionPolicy {
548    /// [`CreditRegistrationState::allowed_targets`] only: what the phases and the precondition
549    /// recompute may take.
550    Pipeline,
551    /// Also [`ADMIN_ONLY_TARGETS`], for a teacher's retry or an admin's hand transition. Whether
552    /// this particular row may take the edge is [`admin_transition_refusal`]'s question.
553    Admin,
554    /// No edge check. Fixtures only: seeds and tests plant a row in a state the pipeline could not
555    /// have reached from where it stands in one move.
556    Planted,
557}
558
559impl TransitionPolicy {
560    fn allows(self, from: CreditRegistrationState, to: CreditRegistrationState) -> bool {
561        match self {
562            Self::Pipeline => from.allowed_targets().contains(&to),
563            Self::Admin => from.allowed_targets().contains(&to) || ADMIN_ONLY_TARGETS.contains(&to),
564            Self::Planted => true,
565        }
566    }
567}
568
569impl Transition {
570    pub fn to(to_state: CreditRegistrationState) -> Self {
571        Self {
572            to_state,
573            error_code: None,
574            error_message: None,
575            needs_admin_attention: None,
576            event_kind: CreditRegistrationEventKind::StateChanged,
577            event_message: None,
578            actor_user_id: None,
579            suotar_api_call_id: None,
580            event_details: None,
581            expected_from_state: None,
582            policy: TransitionPolicy::Pipeline,
583            next_attempt_at: None,
584        }
585    }
586
587    /// A move a human asked for, which may also take the [`ADMIN_ONLY_TARGETS`] edges.
588    pub fn by_hand(to_state: CreditRegistrationState) -> Self {
589        Self {
590            policy: TransitionPolicy::Admin,
591            ..Self::to(to_state)
592        }
593    }
594
595    /// A fixture planting a row in a state directly; see [`TransitionPolicy::Planted`].
596    pub fn planted(to_state: CreditRegistrationState) -> Self {
597        Self {
598            policy: TransitionPolicy::Planted,
599            ..Self::to(to_state)
600        }
601    }
602}
603
604/// Moves a ledger row to a new state and appends the matching audit event, atomically.
605///
606/// The only writer of `state`, and the one place (from → to) legality is decided: an edge outside
607/// the transition's [`TransitionPolicy`] is refused as `InvalidRequest` rather than written.
608/// Deliberately not `PreconditionFailed`, which the phases read as "another writer got here first"
609/// and skip over.
610///
611/// Owns the lifecycle stamps, so callers must not touch them: `state_entered_at`, `terminal_at`,
612/// `first_failed_at`, `registered_at`, `submitted_at`, `enrolment_checked_at`,
613/// `enrolment_banner_dismissed_at`, which entering `no_usable_enrolment` clears, and
614/// `next_attempt_at`, which takes the target state's default cadence unless the caller names a time.
615pub async fn transition(
616    conn: &mut PgConnection,
617    id: Uuid,
618    transition: &Transition,
619) -> ModelResult<CreditRegistration> {
620    let mut tx = conn.begin().await?;
621
622    let before = sqlx::query_as!(
623        CreditRegistration,
624        r#"
625SELECT *
626FROM credit_registrations
627WHERE id = $1
628  AND deleted_at IS NULL
629FOR UPDATE
630        "#,
631        id
632    )
633    .fetch_one(&mut *tx)
634    .await?;
635
636    if let Some(expected) = transition.expected_from_state
637        && before.state != expected
638    {
639        return Err(model_err!(
640            PreconditionFailed,
641            format!(
642                "Credit registration {id} is in {:?}, not the expected {expected:?}: refusing to overwrite it.",
643                before.state
644            )
645        ));
646    }
647
648    let to_state = transition.to_state;
649    check_edge(id, before.state, to_state, transition.policy)?;
650
651    let after = sqlx::query_as!(
652        CreditRegistration,
653        r#"
654UPDATE credit_registrations
655SET state = $2::credit_registration_state,
656  -- clock_timestamp(), not now(): now() is the transaction timestamp, so several state changes in
657  -- one transaction would share an instant and the timeline would lose their order.
658  state_entered_at = clock_timestamp(),
659  error_code = $3,
660  error_message = $4,
661  needs_admin_attention = COALESCE($5, needs_admin_attention),
662  -- ELSE NULL: without it an admin retry stays invisible to every terminal_at IS NULL query.
663  terminal_at = CASE
664    WHEN $6 THEN COALESCE(terminal_at, now())
665    ELSE NULL
666  END,
667  first_failed_at = CASE
668    WHEN $7 THEN COALESCE(first_failed_at, now())
669    ELSE first_failed_at
670  END,
671  registered_at = CASE
672    WHEN $2::credit_registration_state = 'registered' THEN COALESCE(registered_at, now())
673    ELSE registered_at
674  END,
675  submitted_at = CASE
676    WHEN $2::credit_registration_state = 'submitting' THEN now()
677    ELSE submitted_at
678  END,
679  enrolment_checked_at = CASE
680    WHEN state = 'checking_enrolment'
681    AND $2::credit_registration_state <> 'checking_enrolment' THEN now()
682    ELSE enrolment_checked_at
683  END,
684  enrolment_banner_dismissed_at = CASE
685    WHEN $2::credit_registration_state = 'no_usable_enrolment' THEN NULL
686    ELSE enrolment_banner_dismissed_at
687  END,
688  next_attempt_at = COALESCE(
689    $8::timestamptz,
690    now() + ($9::bigint * INTERVAL '1 second')
691  )
692WHERE id = $1
693  AND deleted_at IS NULL
694RETURNING *
695        "#,
696        id,
697        to_state as CreditRegistrationState,
698        transition.error_code as Option<CreditRegistrationErrorCode>,
699        transition.error_message,
700        transition.needs_admin_attention,
701        to_state.is_terminal(),
702        to_state.is_failure(),
703        transition.next_attempt_at,
704        to_state.default_attempt_delay_secs(),
705    )
706    .fetch_one(&mut *tx)
707    .await?;
708
709    crate::credit_registration_events::insert(
710        &mut tx,
711        &NewCreditRegistrationEvent {
712            credit_registration_id: id,
713            kind: transition.event_kind,
714            from_state: Some(before.state),
715            to_state: Some(to_state),
716            error_code: transition.error_code,
717            message: transition.event_message.clone(),
718            suotar_api_call_id: transition.suotar_api_call_id,
719            actor_user_id: transition.actor_user_id,
720            details: transition.event_details.clone(),
721        },
722    )
723    .await?;
724
725    tx.commit().await?;
726    Ok(after)
727}
728
729/// Refuses an edge outside the policy. The one place (from → to) legality is decided, for the
730/// single-row [`transition`] and the batched [`transition_batch`] alike.
731fn check_edge(
732    id: Uuid,
733    from: CreditRegistrationState,
734    to: CreditRegistrationState,
735    policy: TransitionPolicy,
736) -> ModelResult<()> {
737    // Staying put is not a move: the verify poller rewrites its own state on every poll.
738    if from == to || policy.allows(from, to) {
739        return Ok(());
740    }
741    Err(model_err!(
742        InvalidRequest,
743        format!("Credit registration {id} may not move from {from:?} to {to:?} under {policy:?}.")
744    ))
745}
746
747/// One row's move in a [`transition_batch`].
748#[derive(Debug, Clone, PartialEq)]
749pub struct BatchMove {
750    pub id: Uuid,
751    pub transition: Transition,
752}
753
754/// [`transition`] for a whole batch: one lock, one update, one insert of events, whatever the size.
755///
756/// For the phases that decide many rows from one query and have no per-row exchange to record.
757/// Same edge table and policy as [`transition`], and the same event rows; the one difference is
758/// that a row whose state no longer matches `expected_from_state` is left alone rather than
759/// refused, since a batch has no single caller to hand the refusal to. Returns how many moved.
760pub async fn transition_batch(conn: &mut PgConnection, moves: &[BatchMove]) -> ModelResult<i64> {
761    if moves.is_empty() {
762        return Ok(0);
763    }
764    let mut tx = conn.begin().await?;
765    let ids: Vec<Uuid> = moves.iter().map(|batch_move| batch_move.id).collect();
766    let locked = sqlx::query!(
767        r#"
768SELECT id,
769  state
770FROM credit_registrations
771WHERE id = ANY($1)
772  AND deleted_at IS NULL
773ORDER BY id FOR
774UPDATE
775        "#,
776        &ids
777    )
778    .fetch_all(&mut *tx)
779    .await?;
780    let states: HashMap<Uuid, CreditRegistrationState> =
781        locked.into_iter().map(|row| (row.id, row.state)).collect();
782
783    let mut writes = Vec::new();
784    let mut events = Vec::new();
785    for batch_move in moves {
786        let Some(&from) = states.get(&batch_move.id) else {
787            continue;
788        };
789        let to = batch_move.transition.to_state;
790        if batch_move
791            .transition
792            .expected_from_state
793            .is_some_and(|expected| expected != from)
794        {
795            continue;
796        }
797        check_edge(batch_move.id, from, to, batch_move.transition.policy)?;
798        writes.push(batch_move);
799        events.push(NewCreditRegistrationEvent {
800            credit_registration_id: batch_move.id,
801            kind: batch_move.transition.event_kind,
802            from_state: Some(from),
803            to_state: Some(to),
804            error_code: batch_move.transition.error_code,
805            message: batch_move.transition.event_message.clone(),
806            suotar_api_call_id: batch_move.transition.suotar_api_call_id,
807            actor_user_id: batch_move.transition.actor_user_id,
808            details: batch_move.transition.event_details.clone(),
809        });
810    }
811    if writes.is_empty() {
812        tx.commit().await?;
813        return Ok(0);
814    }
815
816    let ids: Vec<Uuid> = writes.iter().map(|write| write.id).collect();
817    let to_states: Vec<CreditRegistrationState> = writes
818        .iter()
819        .map(|write| write.transition.to_state)
820        .collect();
821    let error_codes: Vec<Option<CreditRegistrationErrorCode>> = writes
822        .iter()
823        .map(|write| write.transition.error_code)
824        .collect();
825    let error_messages: Vec<Option<String>> = writes
826        .iter()
827        .map(|write| write.transition.error_message.clone())
828        .collect();
829    let needs_admin: Vec<Option<bool>> = writes
830        .iter()
831        .map(|write| write.transition.needs_admin_attention)
832        .collect();
833    let terminal: Vec<bool> = to_states.iter().map(|state| state.is_terminal()).collect();
834    let failure: Vec<bool> = to_states.iter().map(|state| state.is_failure()).collect();
835    let next_attempts: Vec<Option<DateTime<Utc>>> = writes
836        .iter()
837        .map(|write| write.transition.next_attempt_at)
838        .collect();
839    let default_delays: Vec<i64> = to_states
840        .iter()
841        .map(|state| state.default_attempt_delay_secs())
842        .collect();
843    sqlx::query!(
844        r#"
845UPDATE credit_registrations cr
846SET state = move.to_state,
847  -- clock_timestamp(), not now(): now() is the transaction timestamp, so several state changes in
848  -- one transaction would share an instant and the timeline would lose their order.
849  state_entered_at = clock_timestamp(),
850  error_code = move.error_code,
851  error_message = move.error_message,
852  needs_admin_attention = COALESCE(move.needs_admin_attention, cr.needs_admin_attention),
853  -- ELSE NULL: without it an admin retry stays invisible to every terminal_at IS NULL query.
854  terminal_at = CASE
855    WHEN move.terminal THEN COALESCE(cr.terminal_at, now())
856    ELSE NULL
857  END,
858  first_failed_at = CASE
859    WHEN move.failure THEN COALESCE(cr.first_failed_at, now())
860    ELSE cr.first_failed_at
861  END,
862  registered_at = CASE
863    WHEN move.to_state = 'registered' THEN COALESCE(cr.registered_at, now())
864    ELSE cr.registered_at
865  END,
866  submitted_at = CASE
867    WHEN move.to_state = 'submitting' THEN now()
868    ELSE cr.submitted_at
869  END,
870  enrolment_checked_at = CASE
871    WHEN cr.state = 'checking_enrolment'
872    AND move.to_state <> 'checking_enrolment' THEN now()
873    ELSE cr.enrolment_checked_at
874  END,
875  enrolment_banner_dismissed_at = CASE
876    WHEN move.to_state = 'no_usable_enrolment' THEN NULL
877    ELSE cr.enrolment_banner_dismissed_at
878  END,
879  next_attempt_at = COALESCE(
880    move.next_attempt_at,
881    now() + (move.default_delay_secs * INTERVAL '1 second')
882  )
883FROM UNNEST(
884    $1::uuid [],
885    $2::credit_registration_state [],
886    $3::credit_registration_error_code [],
887    $4::text [],
888    $5::boolean [],
889    $6::boolean [],
890    $7::boolean [],
891    $8::timestamptz [],
892    $9::bigint []
893  ) AS move(
894    id,
895    to_state,
896    error_code,
897    error_message,
898    needs_admin_attention,
899    terminal,
900    failure,
901    next_attempt_at,
902    default_delay_secs
903  )
904WHERE cr.id = move.id
905  AND cr.deleted_at IS NULL
906        "#,
907        &ids,
908        &to_states as &[CreditRegistrationState],
909        &error_codes as &[Option<CreditRegistrationErrorCode>],
910        &error_messages as &[Option<String>],
911        &needs_admin as &[Option<bool>],
912        &terminal,
913        &failure,
914        &next_attempts as &[Option<DateTime<Utc>>],
915        &default_delays,
916    )
917    .execute(&mut *tx)
918    .await?;
919
920    crate::credit_registration_events::insert_batch(&mut tx, &events).await?;
921    tx.commit().await?;
922    Ok(i64::try_from(writes.len()).unwrap_or(i64::MAX))
923}
924
925/// Backdates `state_entered_at` for a registration, so a test can simulate a row that has been
926/// sitting in its state long enough for a backoff or timeout to fire.
927///
928/// Exists only for test setup: [`transition`] owns this stamp, and calling this from a live path
929/// would desynchronize it from the state it is supposed to describe.
930pub async fn set_state_entered_at_for_testing(
931    conn: &mut PgConnection,
932    id: Uuid,
933    state_entered_at: DateTime<Utc>,
934) -> ModelResult<()> {
935    sqlx::query!(
936        "
937UPDATE credit_registrations
938SET state_entered_at = $2
939WHERE id = $1
940        ",
941        id,
942        state_entered_at,
943    )
944    .execute(conn)
945    .await?;
946    Ok(())
947}
948
949/// Backdates `first_failed_at` for a registration, so a test can simulate a retry window that
950/// started long enough ago for its retry limit to have elapsed.
951///
952/// Exists only for test setup: [`transition`] owns this stamp, and calling this from a live path
953/// would desynchronize it from the failure it is supposed to describe.
954pub async fn set_first_failed_at_for_testing(
955    conn: &mut PgConnection,
956    id: Uuid,
957    first_failed_at: DateTime<Utc>,
958) -> ModelResult<()> {
959    sqlx::query!(
960        "
961UPDATE credit_registrations
962SET first_failed_at = $2
963WHERE id = $1
964        ",
965        id,
966        first_failed_at,
967    )
968    .execute(conn)
969    .await?;
970    Ok(())
971}
972
973/// Excuses every one of a user's rows (or, with `course_id`, just that course's) from unscoped
974/// `claim_due` calls until `held_until`; a scoped call ignores every hold regardless (see
975/// `claim_due`). Keyed on identity rather than a row id so a spec can hold before materialize
976/// creates the row it means to protect, closing the window a row-id hold could only ever narrow:
977/// the live background worker ticks every 10s regardless of any single test, so a hold applied
978/// after the row exists still races the worker's own next tick.
979///
980/// Exists only for test setup: nothing in the product ever needs to hide a user's rows from the
981/// worker that owns them.
982pub async fn set_test_exclusive_hold_for_testing(
983    conn: &mut PgConnection,
984    user_id: Uuid,
985    course_id: Option<Uuid>,
986    held_until: DateTime<Utc>,
987) -> ModelResult<()> {
988    sqlx::query!(
989        "
990INSERT INTO credit_registration_test_exclusive_holds (user_id, course_id, held_until)
991VALUES ($1, $2, $3)
992        ",
993        user_id,
994        course_id,
995        held_until,
996    )
997    .execute(conn)
998    .await?;
999    Ok(())
1000}
1001
1002/// Which rows a phase iteration may touch. Empty means every row, which is what production runs; a
1003/// narrowed scope lets a test drive the pipeline for its own course on a shared database.
1004#[derive(Debug, Clone, Default, PartialEq)]
1005pub struct RegistrationScope {
1006    pub course_id: Option<Uuid>,
1007    pub user_id: Option<Uuid>,
1008    /// The precision escape hatch, for a caller that already knows its ledger rows.
1009    pub credit_registration_ids: Vec<Uuid>,
1010}
1011
1012impl RegistrationScope {
1013    pub fn is_unscoped(&self) -> bool {
1014        self.course_id.is_none()
1015            && self.user_id.is_none()
1016            && self.credit_registration_ids.is_empty()
1017    }
1018
1019    pub fn for_course(course_id: Uuid) -> Self {
1020        Self {
1021            course_id: Some(course_id),
1022            ..Self::default()
1023        }
1024    }
1025}
1026
1027/// Claims up to `limit` due rows in the given states for this worker.
1028///
1029/// The row locks live until the caller's transaction ends, so callers must pass a transaction. Rows
1030/// on a paused course module, or on one whose credit registration has been switched off, are never
1031/// claimed: enforced here so no phase can forget it. Both freeze a row where it stands rather than
1032/// cancelling it, so switching the module back on resumes the rows that were already in flight.
1033///
1034/// An unscoped call (the live background worker) also skips a row whose user (and, if the hold
1035/// names one, course) has a live row in `credit_registration_test_exclusive_holds`. A scoped call
1036/// always ignores holds, so a spec driving its own rows through explicit ticks is unaffected
1037/// either way.
1038pub async fn claim_due(
1039    conn: &mut PgConnection,
1040    states: &[CreditRegistrationState],
1041    scope: &RegistrationScope,
1042    limit: i64,
1043) -> ModelResult<Vec<CreditRegistration>> {
1044    let is_scoped_call = !scope.is_unscoped();
1045    let res = sqlx::query_as!(
1046        CreditRegistration,
1047        r#"
1048WITH due AS (
1049  SELECT cr.id
1050  FROM credit_registrations cr
1051    JOIN credit_registration_active_course_modules acm ON acm.course_module_id = cr.course_module_id
1052  WHERE cr.deleted_at IS NULL
1053    AND cr.superseded_by_id IS NULL
1054    AND cr.state = ANY($1::credit_registration_state [])
1055    AND cr.next_attempt_at <= now()
1056    AND ($3::uuid IS NULL OR cr.course_id = $3)
1057    AND ($4::uuid IS NULL OR cr.user_id = $4)
1058    AND (
1059      cardinality($5::uuid []) = 0
1060      OR cr.id = ANY($5::uuid [])
1061    )
1062    AND (
1063      $6::boolean
1064      OR NOT EXISTS (
1065        SELECT 1
1066        FROM credit_registration_test_exclusive_holds h
1067        WHERE h.user_id = cr.user_id
1068          AND (
1069            h.course_id IS NULL
1070            OR h.course_id = cr.course_id
1071          )
1072          AND h.held_until > now()
1073      )
1074    )
1075  ORDER BY cr.next_attempt_at
1076  FOR UPDATE OF cr SKIP LOCKED
1077  LIMIT $2
1078)
1079UPDATE credit_registrations cr
1080SET last_attempt_at = now()
1081FROM due
1082WHERE cr.id = due.id
1083RETURNING cr.*
1084        "#,
1085        states as &[CreditRegistrationState],
1086        limit,
1087        scope.course_id,
1088        scope.user_id,
1089        &scope.credit_registration_ids,
1090        is_scoped_call,
1091    )
1092    .fetch_all(conn)
1093    .await?;
1094    Ok(res)
1095}
1096
1097pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CreditRegistration> {
1098    let res = sqlx::query_as!(
1099        CreditRegistration,
1100        r#"
1101SELECT *
1102FROM credit_registrations
1103WHERE id = $1
1104  AND deleted_at IS NULL
1105        "#,
1106        id
1107    )
1108    .fetch_one(conn)
1109    .await?;
1110    Ok(res)
1111}
1112
1113/// The named rows, locked until the caller's transaction ends. Must be called inside one.
1114///
1115/// For a caller that judges each row and then transitions it: holding the lock is what keeps the
1116/// judgement true, so [`transition`]'s `expected_from_state` cannot fail halfway and roll the whole
1117/// batch back. Locks in id order, which every batch caller shares, so two of them cannot deadlock.
1118pub async fn get_by_ids_for_update(
1119    conn: &mut PgConnection,
1120    ids: &[Uuid],
1121) -> ModelResult<Vec<CreditRegistration>> {
1122    let res = sqlx::query_as!(
1123        CreditRegistration,
1124        r#"
1125SELECT *
1126FROM credit_registrations
1127WHERE id = ANY($1::uuid [])
1128  AND deleted_at IS NULL
1129ORDER BY id FOR UPDATE
1130        "#,
1131        ids
1132    )
1133    .fetch_all(conn)
1134    .await?;
1135    Ok(res)
1136}
1137
1138pub async fn get_by_user_id(
1139    conn: &mut PgConnection,
1140    user_id: Uuid,
1141) -> ModelResult<Vec<CreditRegistration>> {
1142    let res = sqlx::query_as!(
1143        CreditRegistration,
1144        r#"
1145SELECT *
1146FROM credit_registrations
1147WHERE user_id = $1
1148  AND deleted_at IS NULL
1149ORDER BY created_at DESC
1150        "#,
1151        user_id
1152    )
1153    .fetch_all(conn)
1154    .await?;
1155    Ok(res)
1156}
1157
1158pub async fn get_by_course_id(
1159    conn: &mut PgConnection,
1160    course_id: Uuid,
1161) -> ModelResult<Vec<CreditRegistration>> {
1162    let res = sqlx::query_as!(
1163        CreditRegistration,
1164        r#"
1165SELECT *
1166FROM credit_registrations
1167WHERE course_id = $1
1168  AND deleted_at IS NULL
1169ORDER BY created_at DESC
1170        "#,
1171        course_id
1172    )
1173    .fetch_all(conn)
1174    .await?;
1175    Ok(res)
1176}
1177
1178/// One ledger row with the course, module and enrolment facts every student view needs, so a status
1179/// page is one query rather than a fan-out per row.
1180#[derive(Debug, Clone, PartialEq)]
1181pub struct StudentCreditRegistration {
1182    pub id: Uuid,
1183    pub course_id: Uuid,
1184    pub course_name: String,
1185    pub course_slug: String,
1186    pub course_module_id: Uuid,
1187    pub course_module_name: Option<String>,
1188    pub uh_course_code: Option<String>,
1189    pub ects_credits: Option<f32>,
1190    pub course_module_completion_id: Uuid,
1191    pub completion_date: DateTime<Utc>,
1192    pub state: CreditRegistrationState,
1193    pub error_code: Option<CreditRegistrationErrorCode>,
1194    pub next_attempt_at: DateTime<Utc>,
1195    pub registered_at: Option<DateTime<Utc>>,
1196    pub sisu_attainment_id: Option<String>,
1197    pub credits: Option<f32>,
1198    pub grade_id: Option<String>,
1199    /// Needed to read `grade_id`: "1" is a pass on the pass/fail scale and a one out of five on the
1200    /// numeric one.
1201    pub grade_scale_id: Option<String>,
1202    pub attempt_number: i32,
1203    pub superseded_by_id: Option<Uuid>,
1204    pub superseded_at: Option<DateTime<Utc>>,
1205    pub enrolment_checked_at: Option<DateTime<Utc>>,
1206    /// The teacher's label for the realisation we submitted against, not a Sisu id.
1207    pub enrolment_realisation_name: Option<String>,
1208    /// Needed to build the enrolment link a student with no usable enrolment is sent to.
1209    pub open_university_product_id: Option<String>,
1210    pub completion_eligible: bool,
1211    pub has_verified_student_number: bool,
1212}
1213
1214impl StudentCreditRegistration {
1215    /// What a `pending` row is waiting on, which is what its student-facing status is derived from.
1216    pub fn preconditions(&self) -> PendingPreconditions {
1217        PendingPreconditions {
1218            completion_eligible: self.completion_eligible,
1219            has_verified_student_number: self.has_verified_student_number,
1220        }
1221    }
1222}
1223
1224/// Narrows [`get_student_facing_by_user_id`]; the default returns every row of the user's.
1225#[derive(Debug, Default, Clone, Copy)]
1226pub struct StudentRegistrationFilter {
1227    pub course_module_id: Option<Uuid>,
1228    pub course_id: Option<Uuid>,
1229    /// Only rows the in-course re-enrol banner is owed: parked on a missing enrolment and not yet
1230    /// dismissed.
1231    pub enrolment_banner_due: bool,
1232}
1233
1234/// The user's registrations as the student surfaces show them, newest completion first. Superseded
1235/// attempts are included: the student is entitled to see an earlier attempt Sisu may still hold.
1236pub async fn get_student_facing_by_user_id(
1237    conn: &mut PgConnection,
1238    user_id: Uuid,
1239    filter: StudentRegistrationFilter,
1240) -> ModelResult<Vec<StudentCreditRegistration>> {
1241    let res = sqlx::query_as!(
1242        StudentCreditRegistration,
1243        r#"
1244SELECT cr.id,
1245  cr.course_id,
1246  c.name AS course_name,
1247  c.slug AS course_slug,
1248  cr.course_module_id,
1249  cm.name AS course_module_name,
1250  cm.uh_course_code,
1251  cm.ects_credits,
1252  cr.course_module_completion_id,
1253  cmc.completion_date,
1254  cr.state,
1255  cr.error_code AS "error_code?",
1256  cr.next_attempt_at,
1257  cr.registered_at,
1258  cr.sisu_attainment_id,
1259  cr.credits,
1260  cr.grade_id,
1261  cr.grade_scale_id,
1262  cr.attempt_number,
1263  cr.superseded_by_id,
1264  cr.superseded_at,
1265  cr.enrolment_checked_at,
1266  r.label AS "enrolment_realisation_name?",
1267  conf.open_university_product_id AS "open_university_product_id?",
1268  p.completion_eligible AS "completion_eligible!",
1269  p.has_verified_student_number AS "has_verified_student_number!"
1270FROM credit_registrations cr
1271  JOIN courses c ON c.id = cr.course_id
1272  JOIN course_modules cm ON cm.id = cr.course_module_id
1273  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
1274  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
1275  LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cr.course_module_id
1276  AND conf.deleted_at IS NULL
1277  LEFT JOIN course_module_suotar_realisations r ON r.course_module_id = cr.course_module_id
1278  AND r.course_unit_realisation_id = cr.selected_enrolment_realisation_id
1279  AND r.deleted_at IS NULL
1280WHERE cr.user_id = $1
1281  AND cr.deleted_at IS NULL
1282  AND ($2::uuid IS NULL OR cr.course_module_id = $2)
1283  AND ($3::uuid IS NULL OR cr.course_id = $3)
1284  AND (
1285    NOT $4::boolean
1286    OR (
1287      cr.state = 'no_usable_enrolment'
1288      AND cr.enrolment_banner_dismissed_at IS NULL
1289    )
1290  )
1291ORDER BY cmc.completion_date DESC,
1292  cr.attempt_number DESC
1293        "#,
1294        user_id,
1295        filter.course_module_id,
1296        filter.course_id,
1297        filter.enrolment_banner_due,
1298    )
1299    .fetch_all(conn)
1300    .await?;
1301    Ok(res)
1302}
1303
1304/// Frozen copy of what we are about to submit. Written once, before the row leaves
1305/// `checking_enrolment`: a later regrade must not alter a submitted row.
1306#[derive(Debug, Clone, PartialEq)]
1307pub struct PayloadSnapshot {
1308    pub student_number: String,
1309    pub sisu_person_id: String,
1310    pub uh_course_code: String,
1311    pub selected_enrolment_id: Option<String>,
1312    pub selected_enrolment_kind: Option<String>,
1313    pub selected_enrolment_realisation_id: Option<String>,
1314    pub attainment_date: NaiveDate,
1315    pub attainment_language: String,
1316    pub grade_scale_id: String,
1317    pub grade_id: String,
1318    pub credits: f32,
1319}
1320
1321pub async fn set_payload_snapshot(
1322    conn: &mut PgConnection,
1323    id: Uuid,
1324    snapshot: &PayloadSnapshot,
1325) -> ModelResult<()> {
1326    sqlx::query!(
1327        r#"
1328UPDATE credit_registrations
1329SET student_number = $2,
1330  sisu_person_id = $3,
1331  uh_course_code = $4,
1332  selected_enrolment_id = $5,
1333  selected_enrolment_kind = $6,
1334  selected_enrolment_realisation_id = $7,
1335  attainment_date = $8,
1336  attainment_language = $9,
1337  grade_scale_id = $10,
1338  grade_id = $11,
1339  credits = $12
1340WHERE id = $1
1341  AND deleted_at IS NULL
1342        "#,
1343        id,
1344        snapshot.student_number,
1345        snapshot.sisu_person_id,
1346        snapshot.uh_course_code,
1347        snapshot.selected_enrolment_id,
1348        snapshot.selected_enrolment_kind,
1349        snapshot.selected_enrolment_realisation_id,
1350        snapshot.attainment_date,
1351        snapshot.attainment_language,
1352        snapshot.grade_scale_id,
1353        snapshot.grade_id,
1354        snapshot.credits,
1355    )
1356    .execute(conn)
1357    .await?;
1358    Ok(())
1359}
1360
1361pub async fn set_submitted_attainment(
1362    conn: &mut PgConnection,
1363    id: Uuid,
1364    submitted_attainment_id: &str,
1365    submitted_attainment_type: Option<&str>,
1366) -> ModelResult<()> {
1367    sqlx::query!(
1368        r#"
1369UPDATE credit_registrations
1370SET submitted_attainment_id = $2,
1371  submitted_attainment_type = $3
1372WHERE id = $1
1373  AND deleted_at IS NULL
1374        "#,
1375        id,
1376        submitted_attainment_id,
1377        submitted_attainment_type,
1378    )
1379    .execute(conn)
1380    .await?;
1381    Ok(())
1382}
1383
1384/// Records the attainment the study registry holds, unless another live row already claims it.
1385///
1386/// Two rows may legitimately be told about one attainment — a grade improvement Sisu declines names
1387/// the attainment the first attempt registered — so this returns `false` instead of failing.
1388pub async fn set_sisu_attainment_if_unclaimed(
1389    conn: &mut PgConnection,
1390    id: Uuid,
1391    sisu_attainment_id: &str,
1392    sisu_attainment_type: Option<&str>,
1393) -> ModelResult<bool> {
1394    let updated = sqlx::query_scalar!(
1395        r#"
1396UPDATE credit_registrations
1397SET sisu_attainment_id = $2,
1398  sisu_attainment_type = $3
1399WHERE id = $1
1400  AND deleted_at IS NULL
1401  AND NOT EXISTS (
1402    SELECT 1
1403    FROM credit_registrations other
1404    WHERE other.sisu_attainment_id = $2
1405      AND other.deleted_at IS NULL
1406      AND other.id <> $1
1407  )
1408RETURNING id
1409        "#,
1410        id,
1411        sisu_attainment_id,
1412        sisu_attainment_type,
1413    )
1414    .fetch_optional(conn)
1415    .await;
1416    match updated {
1417        Ok(updated) => Ok(updated.is_some()),
1418        // The NOT EXISTS guard above isn't atomic against a concurrent caller claiming the
1419        // same sisu_attainment_id for a different row; the loser hits this unique index instead.
1420        Err(err) => {
1421            let err: ModelError = err.into();
1422            match err.error_type() {
1423                ModelErrorType::DatabaseConstraint { constraint, .. }
1424                    if constraint == "uq_credit_registrations_sisu_attainment" =>
1425                {
1426                    Ok(false)
1427                }
1428                _ => Err(err),
1429            }
1430        }
1431    }
1432}
1433
1434/// Defers when the pipeline may next claim this row; the delay is the caller's policy.
1435///
1436/// Deliberately leaves `first_failed_at` alone: a state that waits on a human defers too, and
1437/// anchoring the retry window here would expire it.
1438pub async fn schedule_next_attempt(
1439    conn: &mut PgConnection,
1440    id: Uuid,
1441    next_attempt_at: DateTime<Utc>,
1442) -> ModelResult<()> {
1443    sqlx::query!(
1444        r#"
1445UPDATE credit_registrations
1446SET next_attempt_at = $2
1447WHERE id = $1
1448  AND deleted_at IS NULL
1449        "#,
1450        id,
1451        next_attempt_at,
1452    )
1453    .execute(conn)
1454    .await?;
1455    Ok(())
1456}
1457
1458/// Makes rows claimable again now, whatever backoff parked them.
1459///
1460/// Uses the database clock: an app-clock value sampled after `BEGIN` is still in the future when
1461/// the same transaction compares it against `now()`.
1462pub async fn make_due_now_batch(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
1463    sqlx::query!(
1464        r#"
1465UPDATE credit_registrations
1466SET next_attempt_at = now()
1467WHERE id = ANY($1)
1468  AND next_attempt_at > now()
1469  AND superseded_by_id IS NULL
1470  AND deleted_at IS NULL
1471        "#,
1472        ids,
1473    )
1474    .execute(conn)
1475    .await?;
1476    Ok(())
1477}
1478
1479/// Brings forward the recheck of rows parked for want of an enrolment, for students the study
1480/// registry now lists as enrolled.
1481///
1482/// Only the clock moves: the enrolment is re-resolved by the precondition recompute rather than
1483/// assumed from a roster entry.
1484pub async fn recheck_no_usable_enrolment_now(
1485    conn: &mut PgConnection,
1486    course_id: Uuid,
1487    user_ids: &[Uuid],
1488) -> ModelResult<u64> {
1489    let res = sqlx::query!(
1490        r#"
1491UPDATE credit_registrations
1492SET next_attempt_at = now()
1493WHERE course_id = $1
1494  AND user_id = ANY($2::uuid [])
1495  AND state = 'no_usable_enrolment'
1496  AND next_attempt_at > now()
1497  AND superseded_by_id IS NULL
1498  AND deleted_at IS NULL
1499        "#,
1500        course_id,
1501        user_ids,
1502    )
1503    .execute(conn)
1504    .await?;
1505    Ok(res.rows_affected())
1506}
1507
1508pub async fn increment_submit_retry_count(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
1509    let res = sqlx::query!(
1510        r#"
1511UPDATE credit_registrations
1512SET submit_retry_count = submit_retry_count + 1
1513WHERE id = $1
1514  AND deleted_at IS NULL
1515RETURNING submit_retry_count
1516        "#,
1517        id
1518    )
1519    .fetch_one(conn)
1520    .await?;
1521    Ok(res.submit_retry_count)
1522}
1523
1524/// Counts one verify poll for every row of a batch and returns each row's new count. The count is
1525/// part of the poll's request item id, so it has to be taken before the request goes out.
1526pub async fn increment_verify_attempt_counts(
1527    conn: &mut PgConnection,
1528    ids: &[Uuid],
1529) -> ModelResult<HashMap<Uuid, i32>> {
1530    let rows = sqlx::query!(
1531        r#"
1532UPDATE credit_registrations
1533SET verify_attempt_count = verify_attempt_count + 1
1534WHERE id = ANY($1)
1535  AND deleted_at IS NULL
1536RETURNING id,
1537  verify_attempt_count
1538        "#,
1539        ids
1540    )
1541    .fetch_all(conn)
1542    .await?;
1543    Ok(rows
1544        .into_iter()
1545        .map(|row| (row.id, row.verify_attempt_count))
1546        .collect())
1547}
1548
1549/// [`schedule_next_attempt`] for a whole batch, each row with its own time.
1550pub async fn schedule_next_attempts(
1551    conn: &mut PgConnection,
1552    scheduled: &[(Uuid, DateTime<Utc>)],
1553) -> ModelResult<()> {
1554    let (ids, times): (Vec<Uuid>, Vec<DateTime<Utc>>) = scheduled.iter().copied().unzip();
1555    sqlx::query!(
1556        r#"
1557UPDATE credit_registrations cr
1558SET next_attempt_at = scheduled.at
1559FROM UNNEST($1::uuid [], $2::timestamptz []) AS scheduled(id, at)
1560WHERE cr.id = scheduled.id
1561  AND cr.deleted_at IS NULL
1562        "#,
1563        &ids,
1564        &times,
1565    )
1566    .execute(conn)
1567    .await?;
1568    Ok(())
1569}
1570
1571pub async fn set_needs_admin_attention(
1572    conn: &mut PgConnection,
1573    id: Uuid,
1574    needs_admin_attention: bool,
1575) -> ModelResult<()> {
1576    sqlx::query!(
1577        r#"
1578UPDATE credit_registrations
1579SET needs_admin_attention = $2
1580WHERE id = $1
1581  AND deleted_at IS NULL
1582        "#,
1583        id,
1584        needs_admin_attention,
1585    )
1586    .execute(conn)
1587    .await?;
1588    Ok(())
1589}
1590
1591/// The student dismissed the in-course-material re-enrol banner for this registration.
1592pub async fn dismiss_enrolment_banner(
1593    conn: &mut PgConnection,
1594    id: Uuid,
1595    user_id: Uuid,
1596) -> ModelResult<()> {
1597    sqlx::query!(
1598        r#"
1599UPDATE credit_registrations
1600SET enrolment_banner_dismissed_at = now()
1601WHERE id = $1
1602  AND user_id = $2
1603  AND deleted_at IS NULL
1604        "#,
1605        id,
1606        user_id,
1607    )
1608    .execute(conn)
1609    .await?;
1610    Ok(())
1611}
1612
1613/// Points an old attempt at the newer one that replaced it. The old row keeps its state and
1614/// `terminal_at`: it really was registered.
1615///
1616/// `superseded_by_id` may name a row that does not exist yet, as long as it is inserted before the
1617/// caller's transaction commits: the foreign key is deferred, which is what lets the successor take
1618/// the completion's one live slot without the old attempt ever pointing at itself.
1619pub async fn mark_superseded(
1620    conn: &mut PgConnection,
1621    id: Uuid,
1622    superseded_by_id: Uuid,
1623) -> ModelResult<()> {
1624    sqlx::query!(
1625        r#"
1626UPDATE credit_registrations
1627SET superseded_by_id = $2,
1628  superseded_at = now()
1629WHERE id = $1
1630  AND deleted_at IS NULL
1631        "#,
1632        id,
1633        superseded_by_id,
1634    )
1635    .execute(conn)
1636    .await?;
1637    Ok(())
1638}
1639
1640/// Whether this account has any attempt, live or replaced, on this course.
1641///
1642/// For course-scoped handlers that take a user id from a request body: without it, holding one
1643/// course lets a teacher ask questions about accounts that have nothing to do with it.
1644pub async fn exists_for_user_and_course(
1645    conn: &mut PgConnection,
1646    user_id: Uuid,
1647    course_id: Uuid,
1648) -> ModelResult<bool> {
1649    let exists = sqlx::query_scalar!(
1650        r#"
1651SELECT EXISTS (
1652    SELECT 1
1653    FROM credit_registrations
1654    WHERE user_id = $1
1655      AND course_id = $2
1656      AND deleted_at IS NULL
1657  ) AS "exists!"
1658        "#,
1659        user_id,
1660        course_id,
1661    )
1662    .fetch_one(conn)
1663    .await?;
1664    Ok(exists)
1665}
1666
1667/// Records that the grade-improvement scan looked at this accepted attempt against a completion in
1668/// the given revision and found nothing better.
1669///
1670/// `completion_updated_at` must be the `updated_at` the scan actually read, not `now()`: the point is
1671/// that the row stops being a candidate until the completion changes again.
1672pub async fn mark_improvement_checked(
1673    conn: &mut PgConnection,
1674    id: Uuid,
1675    completion_updated_at: DateTime<Utc>,
1676) -> ModelResult<()> {
1677    sqlx::query!(
1678        r#"
1679UPDATE credit_registrations
1680SET improvement_checked_completion_updated_at = $2
1681WHERE id = $1
1682  AND deleted_at IS NULL
1683        "#,
1684        id,
1685        completion_updated_at,
1686    )
1687    .execute(conn)
1688    .await?;
1689    Ok(())
1690}
1691
1692/// The course's live rows a bulk retry can actually move: failed for good. Oldest first, capped by
1693/// `limit`.
1694///
1695/// Deliberately only these. A row a retry always refuses keeps matching for as long as it exists, so
1696/// letting one into the batch would spend a slot of the cap on it forever: a course holding `limit`
1697/// of them could never retry anything again. [`count_submission_uncertain_by_course_id`] is what
1698/// reports them.
1699pub async fn get_retryable_ids_by_course_id(
1700    conn: &mut PgConnection,
1701    course_id: Uuid,
1702    limit: i64,
1703) -> ModelResult<Vec<Uuid>> {
1704    let res = sqlx::query_scalar!(
1705        r#"
1706SELECT id
1707FROM credit_registrations cr
1708WHERE cr.course_id = $1
1709  AND cr.state = 'failed_permanent'
1710  AND cr.superseded_by_id IS NULL
1711  AND cr.deleted_at IS NULL
1712ORDER BY cr.state_entered_at
1713LIMIT $2
1714        "#,
1715        course_id,
1716        limit,
1717    )
1718    .fetch_all(conn)
1719    .await?;
1720    Ok(res)
1721}
1722
1723/// How many of a course's live rows a bulk retry has to refuse, all for the one remaining reason:
1724/// the submission may have landed, so only a human may move that row.
1725///
1726/// Counts the whole course, not a capped window: these are the rows
1727/// [`get_retryable_ids_by_course_id`] leaves out, and a teacher clicking again will never work
1728/// through them.
1729pub async fn count_submission_uncertain_by_course_id(
1730    conn: &mut PgConnection,
1731    course_id: Uuid,
1732) -> ModelResult<i64> {
1733    let count = sqlx::query_scalar!(
1734        r#"
1735SELECT COUNT(*) AS "count!"
1736FROM credit_registrations cr
1737WHERE cr.course_id = $1
1738  AND cr.state = 'submission_uncertain'
1739  AND cr.superseded_by_id IS NULL
1740  AND cr.deleted_at IS NULL
1741        "#,
1742        course_id,
1743    )
1744    .fetch_one(conn)
1745    .await?;
1746    Ok(count)
1747}
1748
1749/// Live rows per state, for the dashboard funnel. Superseded attempts are excluded, as in the
1750/// per-course sibling, or a course that regrades counts every student twice.
1751pub async fn count_by_state(
1752    conn: &mut PgConnection,
1753) -> ModelResult<Vec<(CreditRegistrationState, i64)>> {
1754    let rows = sqlx::query!(
1755        r#"
1756SELECT state,
1757  COUNT(*) AS "count!"
1758FROM credit_registrations
1759WHERE superseded_by_id IS NULL
1760  AND deleted_at IS NULL
1761GROUP BY state
1762        "#,
1763    )
1764    .fetch_all(conn)
1765    .await?;
1766    Ok(rows.into_iter().map(|r| (r.state, r.count)).collect())
1767}
1768
1769/// Live `pending` rows per blocker, for the surfaces that used to read the three collapsed states
1770/// off the ledger. Derived from `credit_registration_preconditions`, so it cannot disagree with what
1771/// the recompute is waiting for or with what the student is shown.
1772pub async fn count_pending_by_reason(conn: &mut PgConnection) -> ModelResult<PendingReasonCounts> {
1773    let row = sqlx::query!(
1774        r#"
1775SELECT COUNT(*) FILTER (
1776    WHERE NOT p.completion_eligible
1777  ) AS "completion_count!",
1778  COUNT(*) FILTER (
1779    WHERE p.completion_eligible
1780      AND NOT p.has_verified_student_number
1781  ) AS "student_number_count!"
1782FROM credit_registrations cr
1783  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
1784WHERE cr.state = 'pending'
1785  AND cr.superseded_by_id IS NULL
1786  AND cr.deleted_at IS NULL
1787        "#,
1788    )
1789    .fetch_one(conn)
1790    .await?;
1791    Ok(PendingReasonCounts {
1792        completion_count: row.completion_count,
1793        student_number_count: row.student_number_count,
1794    })
1795}
1796
1797/// Live rows of one course per module and state, for the teacher's per-module summary, with how many
1798/// of each need a human folded in: both counts are read off the same scan, since the summary always
1799/// wants them together.
1800pub async fn count_by_module_and_state_for_course(
1801    conn: &mut PgConnection,
1802    course_id: Uuid,
1803) -> ModelResult<Vec<(Uuid, CreditRegistrationState, i64, i64)>> {
1804    let rows = sqlx::query!(
1805        r#"
1806SELECT course_module_id,
1807  state,
1808  COUNT(*) AS "count!",
1809  COUNT(*) FILTER (WHERE needs_admin_attention) AS "needs_admin_attention_count!"
1810FROM credit_registrations
1811WHERE course_id = $1
1812  AND superseded_by_id IS NULL
1813  AND deleted_at IS NULL
1814GROUP BY course_module_id,
1815  state
1816        "#,
1817        course_id
1818    )
1819    .fetch_all(conn)
1820    .await?;
1821    Ok(rows
1822        .into_iter()
1823        .map(|r| {
1824            (
1825                r.course_module_id,
1826                r.state,
1827                r.count,
1828                r.needs_admin_attention_count,
1829            )
1830        })
1831        .collect())
1832}
1833
1834/// One ledger row as a teacher sees it: the raw state, the student's identity and the unmasked
1835/// verified student number, but never the study registry's own error text.
1836#[derive(Debug, Clone, PartialEq)]
1837pub struct TeacherCreditRegistration {
1838    pub id: Uuid,
1839    pub user_id: Uuid,
1840    pub first_name: Option<String>,
1841    pub last_name: Option<String>,
1842    pub email: Option<String>,
1843    pub course_id: Uuid,
1844    pub course_module_id: Uuid,
1845    pub course_module_name: Option<String>,
1846    pub course_instance_id: Uuid,
1847    pub course_module_completion_id: Uuid,
1848    pub completion_date: DateTime<Utc>,
1849    pub state: CreditRegistrationState,
1850    pub state_entered_at: DateTime<Utc>,
1851    pub error_code: Option<CreditRegistrationErrorCode>,
1852    pub needs_admin_attention: bool,
1853    pub next_attempt_at: DateTime<Utc>,
1854    pub registered_at: Option<DateTime<Utc>>,
1855    pub sisu_attainment_id: Option<String>,
1856    pub grade_id: Option<String>,
1857    pub credits: Option<f32>,
1858    pub attempt_number: i32,
1859    pub superseded_by_id: Option<Uuid>,
1860    /// Live only: a soft-deleted link is no longer a number we hold for this student.
1861    pub student_number: Option<String>,
1862    pub student_number_verified_at: Option<DateTime<Utc>>,
1863    pub student_number_verified_via: Option<StudentNumberVerificationMethod>,
1864    /// Needed to find the account's linking mails, which are keyed on the Sisu person.
1865    pub sisu_person_id: Option<String>,
1866    pub enrolment_realisation_name: Option<String>,
1867    pub completion_eligible: bool,
1868    /// The page's total row count, so a caller can read it off the first row instead of a second query.
1869    pub total_count: i64,
1870}
1871
1872impl TeacherCreditRegistration {
1873    /// What a `pending` row is waiting on. The linked number is the row's own `student_number`,
1874    /// which is the live link rather than the one a submitted payload froze.
1875    pub fn preconditions(&self) -> PendingPreconditions {
1876        PendingPreconditions {
1877            completion_eligible: self.completion_eligible,
1878            has_verified_student_number: self.student_number.is_some(),
1879        }
1880    }
1881}
1882
1883/// The optional narrowings a teacher surface applies, all of them in SQL.
1884#[derive(Debug, Clone, Default)]
1885pub struct TeacherCreditRegistrationFilters<'a> {
1886    pub id: Option<Uuid>,
1887    pub user_ids: Option<&'a [Uuid]>,
1888    pub state: Option<CreditRegistrationState>,
1889    /// Matched against the student's name, email or verified student number.
1890    pub search: Option<&'a str>,
1891    pub course_instance_id: Option<Uuid>,
1892    /// Narrows to every attempt of one completion.
1893    pub course_module_completion_id: Option<Uuid>,
1894}
1895
1896/// The one query behind every teacher-facing read, so a filter wired into a page cannot be missed
1897/// in its count. `total_count` is computed before the limit, which is why the count reads it with
1898/// `limit = 1`.
1899async fn teacher_facing_page(
1900    conn: &mut PgConnection,
1901    course_id: Option<Uuid>,
1902    filters: &TeacherCreditRegistrationFilters<'_>,
1903    limit: i64,
1904    offset: i64,
1905) -> ModelResult<Vec<TeacherCreditRegistration>> {
1906    let search_pattern = filters.search.map(search_pattern_of);
1907    let res = sqlx::query_as!(
1908        TeacherCreditRegistration,
1909        r#"
1910SELECT cr.id,
1911  cr.user_id,
1912  ud.first_name AS "first_name?",
1913  ud.last_name AS "last_name?",
1914  ud.email AS "email?",
1915  cr.course_id,
1916  cr.course_module_id,
1917  cm.name AS course_module_name,
1918  cr.course_instance_id,
1919  cr.course_module_completion_id,
1920  cmc.completion_date,
1921  cr.state,
1922  cr.state_entered_at,
1923  cr.error_code AS "error_code?",
1924  cr.needs_admin_attention,
1925  cr.next_attempt_at,
1926  cr.registered_at,
1927  cr.sisu_attainment_id,
1928  cr.grade_id,
1929  cr.credits,
1930  cr.attempt_number,
1931  cr.superseded_by_id,
1932  vsn.student_number AS "student_number?",
1933  vsn.verified_at AS "student_number_verified_at?",
1934  vsn.verified_via AS "student_number_verified_via?",
1935  vsn.sisu_person_id AS "sisu_person_id?",
1936  r.label AS "enrolment_realisation_name?",
1937  p.completion_eligible AS "completion_eligible!",
1938  COUNT(*) OVER () AS "total_count!"
1939FROM credit_registrations cr
1940  JOIN course_modules cm ON cm.id = cr.course_module_id
1941  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
1942  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
1943  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
1944  LEFT JOIN verified_student_numbers vsn ON vsn.user_id = cr.user_id
1945  AND vsn.deleted_at IS NULL
1946  LEFT JOIN course_module_suotar_realisations r ON r.course_module_id = cr.course_module_id
1947  AND r.course_unit_realisation_id = cr.selected_enrolment_realisation_id
1948  AND r.deleted_at IS NULL
1949WHERE cr.deleted_at IS NULL
1950  AND ($1::uuid IS NULL OR cr.course_id = $1)
1951  AND ($2::uuid IS NULL OR cr.id = $2)
1952  AND ($3::uuid [] IS NULL OR cr.user_id = ANY($3))
1953  AND (
1954    $4::credit_registration_state IS NULL
1955    OR cr.state = $4
1956  )
1957  AND (
1958    $5::text IS NULL
1959    OR ud.name_search_helper LIKE '%' || $5 || '%' ESCAPE '\'
1960    OR ud.email_search_helper LIKE '%' || $5 || '%' ESCAPE '\'
1961    OR LOWER(vsn.student_number) LIKE '%' || $5 || '%' ESCAPE '\'
1962  )
1963  AND ($6::uuid IS NULL OR cr.course_instance_id = $6)
1964  AND ($7::uuid IS NULL OR cr.course_module_completion_id = $7)
1965ORDER BY cmc.completion_date DESC,
1966  cr.attempt_number DESC,
1967  cr.id
1968LIMIT $8 OFFSET $9
1969        "#,
1970        course_id,
1971        filters.id,
1972        filters.user_ids,
1973        filters.state as Option<CreditRegistrationState>,
1974        search_pattern.as_deref(),
1975        filters.course_instance_id,
1976        filters.course_module_completion_id,
1977        limit,
1978        offset,
1979    )
1980    .fetch_all(conn)
1981    .await?;
1982    Ok(res)
1983}
1984
1985/// The course's ledger rows as the teacher surfaces show them, newest completion first.
1986pub async fn get_teacher_facing_by_course_id(
1987    conn: &mut PgConnection,
1988    course_id: Uuid,
1989    filters: &TeacherCreditRegistrationFilters<'_>,
1990    limit: i64,
1991    offset: i64,
1992) -> ModelResult<Vec<TeacherCreditRegistration>> {
1993    teacher_facing_page(conn, Some(course_id), filters, limit, offset).await
1994}
1995
1996/// How many rows [`get_teacher_facing_by_course_id`] would return without a page limit.
1997pub async fn count_teacher_facing_by_course_id(
1998    conn: &mut PgConnection,
1999    course_id: Uuid,
2000    filters: &TeacherCreditRegistrationFilters<'_>,
2001) -> ModelResult<i64> {
2002    let rows = teacher_facing_page(conn, Some(course_id), filters, 1, 0).await?;
2003    Ok(rows.first().map_or(0, |row| row.total_count))
2004}
2005
2006/// Lowercased and with metacharacters escaped, so a search for `%` matches a literal one.
2007fn search_pattern_of(search: &str) -> String {
2008    escape_like_pattern(&search.to_lowercase())
2009}
2010
2011/// One row for a teacher surface, by id. `None` when no such live row exists.
2012pub async fn get_teacher_facing_by_id(
2013    conn: &mut PgConnection,
2014    id: Uuid,
2015) -> ModelResult<Option<TeacherCreditRegistration>> {
2016    let rows = teacher_facing_page(
2017        conn,
2018        None,
2019        &TeacherCreditRegistrationFilters {
2020            id: Some(id),
2021            ..TeacherCreditRegistrationFilters::default()
2022        },
2023        1,
2024        0,
2025    )
2026    .await?;
2027    Ok(rows.into_iter().next())
2028}
2029
2030/// Every attempt for the same completion as `row`, that one included, newest attempt first.
2031pub async fn get_teacher_facing_attempts_for_completion(
2032    conn: &mut PgConnection,
2033    row: &TeacherCreditRegistration,
2034) -> ModelResult<Vec<TeacherCreditRegistration>> {
2035    get_teacher_facing_by_course_id(
2036        conn,
2037        row.course_id,
2038        &TeacherCreditRegistrationFilters {
2039            user_ids: Some(&[row.user_id]),
2040            course_module_completion_id: Some(row.course_module_completion_id),
2041            ..TeacherCreditRegistrationFilters::default()
2042        },
2043        i64::MAX,
2044        0,
2045    )
2046    .await
2047}
2048
2049/// One ledger row as an admin sees it: every identifier support needs to answer "what happened to
2050/// this student", across courses.
2051///
2052/// Not the study registry's own error text: it is written for an integrator, may name a person and
2053/// is untranslated. The error code and the scrubbed call bodies stand in for it.
2054#[derive(Debug, Clone, PartialEq)]
2055pub struct AdminCreditRegistration {
2056    pub id: Uuid,
2057    pub created_at: DateTime<Utc>,
2058    pub user_id: Uuid,
2059    pub first_name: Option<String>,
2060    pub last_name: Option<String>,
2061    /// In full: the admin view exists to resolve support cases, which starts from the address.
2062    pub email: Option<String>,
2063    pub course_id: Uuid,
2064    pub course_name: String,
2065    pub course_module_id: Uuid,
2066    pub course_module_name: Option<String>,
2067    pub course_instance_id: Uuid,
2068    pub course_module_completion_id: Uuid,
2069    pub completion_date: DateTime<Utc>,
2070    pub state: CreditRegistrationState,
2071    pub state_entered_at: DateTime<Utc>,
2072    pub error_code: Option<CreditRegistrationErrorCode>,
2073    pub needs_admin_attention: bool,
2074    pub next_attempt_at: DateTime<Utc>,
2075    pub last_attempt_at: Option<DateTime<Utc>>,
2076    pub submitted_at: Option<DateTime<Utc>>,
2077    pub registered_at: Option<DateTime<Utc>>,
2078    pub terminal_at: Option<DateTime<Utc>>,
2079    /// Frozen on the row when it left `checking_enrolment`, so it is what we actually sent.
2080    pub student_number: Option<String>,
2081    pub sisu_person_id: Option<String>,
2082    pub uh_course_code: Option<String>,
2083    pub selected_enrolment_id: Option<String>,
2084    pub grade_scale_id: Option<String>,
2085    pub grade_id: Option<String>,
2086    pub credits: Option<f32>,
2087    pub request_item_id: String,
2088    pub submitted_attainment_id: Option<String>,
2089    pub sisu_attainment_id: Option<String>,
2090    pub submit_retry_count: i32,
2091    pub verify_attempt_count: i32,
2092    pub attempt_number: i32,
2093    pub superseded_by_id: Option<Uuid>,
2094    /// The account's live link now, which may differ from the number frozen on the row.
2095    pub verified_student_number: Option<String>,
2096    pub verified_student_number_at: Option<DateTime<Utc>>,
2097    pub verified_student_number_via: Option<StudentNumberVerificationMethod>,
2098    pub completion_eligible: bool,
2099    pub has_verified_student_number: bool,
2100    /// The page's total row count, so a caller can read it off the first row instead of a second query.
2101    pub total_count: i64,
2102}
2103
2104impl AdminCreditRegistration {
2105    /// What this row is waiting on, or `None` where it is not waiting at all: outside `pending` the
2106    /// preconditions say nothing about why the row is where it is.
2107    pub fn pending_reason(&self) -> Option<CreditRegistrationPendingReason> {
2108        (self.state == CreditRegistrationState::Pending)
2109            .then(|| {
2110                PendingPreconditions {
2111                    completion_eligible: self.completion_eligible,
2112                    has_verified_student_number: self.has_verified_student_number,
2113                }
2114                .reason()
2115            })
2116            .flatten()
2117    }
2118}
2119
2120/// How the explorer orders a page. Descending only: an ops table is read newest-worst first.
2121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2122pub enum AdminCreditRegistrationSort {
2123    #[default]
2124    LastActivity,
2125    Created,
2126    TimeInState,
2127    Attempts,
2128}
2129
2130impl AdminCreditRegistrationSort {
2131    /// Bound into the query's `ORDER BY` as a `text` parameter.
2132    fn as_str(self) -> &'static str {
2133        match self {
2134            Self::LastActivity => "last_activity",
2135            Self::Created => "created",
2136            Self::TimeInState => "time_in_state",
2137            Self::Attempts => "attempts",
2138        }
2139    }
2140}
2141
2142/// The narrowings the admin explorer applies, all of them in SQL.
2143#[derive(Debug, Clone, Default)]
2144pub struct AdminCreditRegistrationFilters<'a> {
2145    pub states: Option<&'a [CreditRegistrationState]>,
2146    pub error_codes: Option<&'a [CreditRegistrationErrorCode]>,
2147    pub course_id: Option<Uuid>,
2148    pub course_module_id: Option<Uuid>,
2149    pub user_id: Option<Uuid>,
2150    pub student_number: Option<&'a str>,
2151    pub needs_admin_attention: bool,
2152    pub submitted_after: Option<DateTime<Utc>>,
2153    pub submitted_before: Option<DateTime<Utc>>,
2154    /// Matched against the student's name and email, either student number, the attainment ids and
2155    /// the stored error text. Searching that text is not rendering it.
2156    pub search: Option<&'a str>,
2157    /// A uuid typed into the search box: a registration, a user or a completion id. Ambiguous by
2158    /// design, for a human's paste. A caller that already knows which single field it means should
2159    /// use `id` or `course_module_completion_id` instead, not this plus a Rust-side filter.
2160    pub search_id: Option<Uuid>,
2161    /// Exactly one registration.
2162    pub id: Option<Uuid>,
2163    /// Every attempt against one completion.
2164    pub course_module_completion_id: Option<Uuid>,
2165    /// An exact set of rows, for a caller that already knows which ones it wants.
2166    pub credit_registration_ids: Option<&'a [Uuid]>,
2167    /// Off by default, or a course that regrades shows two rows per student.
2168    pub include_superseded: bool,
2169}
2170
2171/// The one query behind both [`get_admin_facing`] and [`count_admin_facing`], so a filter wired
2172/// into one cannot be missed in the other. `total_count` is computed before the limit, which is why
2173/// `count_admin_facing` reads it with `limit = 1`.
2174async fn admin_facing_page(
2175    conn: &mut PgConnection,
2176    filters: &AdminCreditRegistrationFilters<'_>,
2177    sort: AdminCreditRegistrationSort,
2178    limit: i64,
2179    offset: i64,
2180) -> ModelResult<Vec<AdminCreditRegistration>> {
2181    let search_pattern = filters.search.map(search_pattern_of);
2182    let res = sqlx::query_as!(
2183        AdminCreditRegistration,
2184        r#"
2185SELECT cr.id,
2186  cr.created_at,
2187  cr.user_id,
2188  ud.first_name AS "first_name?",
2189  ud.last_name AS "last_name?",
2190  ud.email AS "email?",
2191  cr.course_id,
2192  c.name AS course_name,
2193  cr.course_module_id,
2194  cm.name AS course_module_name,
2195  cr.course_instance_id,
2196  cr.course_module_completion_id,
2197  cmc.completion_date,
2198  cr.state,
2199  cr.state_entered_at,
2200  cr.error_code AS "error_code?",
2201  cr.needs_admin_attention,
2202  cr.next_attempt_at,
2203  cr.last_attempt_at,
2204  cr.submitted_at,
2205  cr.registered_at,
2206  cr.terminal_at,
2207  cr.student_number,
2208  cr.sisu_person_id,
2209  cr.uh_course_code,
2210  cr.selected_enrolment_id,
2211  cr.grade_scale_id,
2212  cr.grade_id,
2213  cr.credits,
2214  cr.request_item_id,
2215  cr.submitted_attainment_id,
2216  cr.sisu_attainment_id,
2217  cr.submit_retry_count,
2218  cr.verify_attempt_count,
2219  cr.attempt_number,
2220  cr.superseded_by_id,
2221  vsn.student_number AS "verified_student_number?",
2222  vsn.verified_at AS "verified_student_number_at?",
2223  vsn.verified_via AS "verified_student_number_via?",
2224  p.completion_eligible AS "completion_eligible!",
2225  p.has_verified_student_number AS "has_verified_student_number!",
2226  COUNT(*) OVER () AS "total_count!"
2227FROM credit_registrations cr
2228  JOIN courses c ON c.id = cr.course_id
2229  JOIN course_modules cm ON cm.id = cr.course_module_id
2230  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
2231  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
2232  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
2233  LEFT JOIN verified_student_numbers vsn ON vsn.user_id = cr.user_id
2234  AND vsn.deleted_at IS NULL
2235WHERE cr.deleted_at IS NULL
2236  AND ($1::bool OR cr.superseded_by_id IS NULL)
2237  AND (
2238    $2::credit_registration_state [] IS NULL
2239    OR cr.state = ANY($2)
2240  )
2241  AND (
2242    $3::credit_registration_error_code [] IS NULL
2243    OR cr.error_code = ANY($3)
2244  )
2245  AND ($4::uuid IS NULL OR cr.course_id = $4)
2246  AND ($5::uuid IS NULL OR cr.course_module_id = $5)
2247  AND ($6::uuid IS NULL OR cr.user_id = $6)
2248  AND (
2249    $7::text IS NULL
2250    OR cr.student_number = $7
2251    OR vsn.student_number = $7
2252  )
2253  AND (NOT $8::bool OR cr.needs_admin_attention)
2254  AND ($9::timestamptz IS NULL OR cr.submitted_at >= $9)
2255  AND ($10::timestamptz IS NULL OR cr.submitted_at <= $10)
2256  AND (
2257    $11::text IS NULL
2258    OR ud.name_search_helper LIKE '%' || $11 || '%' ESCAPE '\'
2259    OR ud.email_search_helper LIKE '%' || $11 || '%' ESCAPE '\'
2260    OR LOWER(cr.student_number) LIKE '%' || $11 || '%' ESCAPE '\'
2261    OR LOWER(vsn.student_number) LIKE '%' || $11 || '%' ESCAPE '\'
2262    OR LOWER(cr.submitted_attainment_id) LIKE '%' || $11 || '%' ESCAPE '\'
2263    OR LOWER(cr.sisu_attainment_id) LIKE '%' || $11 || '%' ESCAPE '\'
2264    OR LOWER(cr.error_message) LIKE '%' || $11 || '%' ESCAPE '\'
2265  )
2266  AND (
2267    $12::uuid IS NULL
2268    OR cr.id = $12
2269    OR cr.user_id = $12
2270    OR cr.course_module_completion_id = $12
2271  )
2272  AND (
2273    $13::uuid [] IS NULL
2274    OR cr.id = ANY($13)
2275  )
2276  AND ($17::uuid IS NULL OR cr.id = $17)
2277  AND (
2278    $18::uuid IS NULL
2279    OR cr.course_module_completion_id = $18
2280  )
2281ORDER BY CASE
2282    WHEN $14::text = 'attempts' THEN cr.submit_retry_count + cr.verify_attempt_count
2283  END DESC NULLS LAST,
2284  CASE $14::text
2285    WHEN 'created' THEN cr.created_at
2286    WHEN 'time_in_state' THEN cr.state_entered_at
2287    ELSE COALESCE(cr.last_attempt_at, cr.state_entered_at)
2288  END DESC,
2289  cr.id
2290LIMIT $15 OFFSET $16
2291        "#,
2292        filters.include_superseded,
2293        filters.states as Option<&[CreditRegistrationState]>,
2294        filters.error_codes as Option<&[CreditRegistrationErrorCode]>,
2295        filters.course_id,
2296        filters.course_module_id,
2297        filters.user_id,
2298        filters.student_number,
2299        filters.needs_admin_attention,
2300        filters.submitted_after,
2301        filters.submitted_before,
2302        search_pattern.as_deref(),
2303        filters.search_id,
2304        filters.credit_registration_ids as Option<&[Uuid]>,
2305        sort.as_str(),
2306        limit,
2307        offset,
2308        filters.id,
2309        filters.course_module_completion_id,
2310    )
2311    .fetch_all(conn)
2312    .await?;
2313    Ok(res)
2314}
2315
2316/// A page of the ledger for the admin explorer, cross-course.
2317pub async fn get_admin_facing(
2318    conn: &mut PgConnection,
2319    filters: &AdminCreditRegistrationFilters<'_>,
2320    sort: AdminCreditRegistrationSort,
2321    limit: i64,
2322    offset: i64,
2323) -> ModelResult<Vec<AdminCreditRegistration>> {
2324    admin_facing_page(conn, filters, sort, limit, offset).await
2325}
2326
2327/// How many rows [`get_admin_facing`] would return without a page limit.
2328pub async fn count_admin_facing(
2329    conn: &mut PgConnection,
2330    filters: &AdminCreditRegistrationFilters<'_>,
2331) -> ModelResult<i64> {
2332    let rows =
2333        admin_facing_page(conn, filters, AdminCreditRegistrationSort::default(), 1, 0).await?;
2334    Ok(rows.first().map_or(0, |row| row.total_count))
2335}
2336
2337/// Live rows carrying an error code, split by whether the pipeline is still working on them.
2338#[derive(Debug, Clone, PartialEq)]
2339pub struct CreditRegistrationErrorCodeCount {
2340    pub error_code: CreditRegistrationErrorCode,
2341    pub in_flight_count: i64,
2342    pub terminal_failure_count: i64,
2343}
2344
2345/// The error-code breakdown the Overview shows.
2346pub async fn count_by_error_code(
2347    conn: &mut PgConnection,
2348) -> ModelResult<Vec<CreditRegistrationErrorCodeCount>> {
2349    let rows = sqlx::query!(
2350        r#"
2351SELECT error_code AS "error_code!",
2352  COUNT(*) FILTER (WHERE terminal_at IS NULL) AS "in_flight_count!",
2353  COUNT(*) FILTER (
2354    WHERE state = ANY($1::credit_registration_state [])
2355  ) AS "terminal_failure_count!"
2356FROM credit_registrations
2357WHERE error_code IS NOT NULL
2358  AND superseded_by_id IS NULL
2359  AND deleted_at IS NULL
2360GROUP BY error_code
2361ORDER BY COUNT(*) DESC
2362        "#,
2363        &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
2364    )
2365    .fetch_all(conn)
2366    .await?;
2367    Ok(rows
2368        .into_iter()
2369        .map(|row| CreditRegistrationErrorCodeCount {
2370            error_code: row.error_code,
2371            in_flight_count: row.in_flight_count,
2372            terminal_failure_count: row.terminal_failure_count,
2373        })
2374        .collect())
2375}
2376
2377pub async fn count_needing_admin_attention(conn: &mut PgConnection) -> ModelResult<i64> {
2378    let count = sqlx::query_scalar!(
2379        r#"
2380SELECT COUNT(*) AS "count!"
2381FROM credit_registrations
2382WHERE needs_admin_attention
2383  AND superseded_by_id IS NULL
2384  AND deleted_at IS NULL
2385        "#,
2386    )
2387    .fetch_one(conn)
2388    .await?;
2389    Ok(count)
2390}
2391
2392/// The row that has been waiting longest for the pipeline to do something with it.
2393#[derive(Debug, Clone, PartialEq)]
2394pub struct OldestNonTerminalRegistration {
2395    pub id: Uuid,
2396    pub state: CreditRegistrationState,
2397    pub state_entered_at: DateTime<Utc>,
2398}
2399
2400pub async fn get_oldest_non_terminal(
2401    conn: &mut PgConnection,
2402) -> ModelResult<Option<OldestNonTerminalRegistration>> {
2403    let row = sqlx::query_as!(
2404        OldestNonTerminalRegistration,
2405        r#"
2406SELECT id,
2407  state,
2408  state_entered_at
2409FROM credit_registrations
2410WHERE terminal_at IS NULL
2411  AND superseded_by_id IS NULL
2412  AND deleted_at IS NULL
2413ORDER BY state_entered_at
2414LIMIT 1
2415        "#,
2416    )
2417    .fetch_optional(conn)
2418    .await?;
2419    Ok(row)
2420}
2421
2422/// One day of terminal outcomes, for the throughput series.
2423#[derive(Debug, Clone, PartialEq)]
2424pub struct CreditRegistrationThroughputDay {
2425    pub day: DateTime<Utc>,
2426    pub registered_count: i64,
2427    pub other_success_count: i64,
2428    pub failed_count: i64,
2429}
2430
2431/// Daily terminal outcomes over the window. Withdrawn rows are in no column: they are neither a
2432/// success nor a failure.
2433pub async fn get_throughput_by_day(
2434    conn: &mut PgConnection,
2435    since: DateTime<Utc>,
2436) -> ModelResult<Vec<CreditRegistrationThroughputDay>> {
2437    let rows = sqlx::query_as!(
2438        CreditRegistrationThroughputDay,
2439        r#"
2440SELECT DATE_TRUNC('day', terminal_at) AS "day!",
2441  COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
2442  COUNT(*) FILTER (
2443    WHERE state = ANY($2::credit_registration_state [])
2444  ) AS "other_success_count!",
2445  COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_count!"
2446FROM credit_registrations
2447WHERE terminal_at >= $1
2448  AND superseded_by_id IS NULL
2449  AND deleted_at IS NULL
2450GROUP BY 1
2451ORDER BY 1
2452        "#,
2453        since,
2454        &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
2455    )
2456    .fetch_all(conn)
2457    .await?;
2458    Ok(rows)
2459}
2460
2461/// What the pipeline finished in a window.
2462#[derive(Debug, Clone, PartialEq, Default)]
2463pub struct TerminalOutcomeTotals {
2464    /// `registered`, `duplicate` and `not_improved`.
2465    pub success_count: i64,
2466    /// The subset we put in the registry ourselves.
2467    pub registered_count: i64,
2468    pub failed_permanent_count: i64,
2469    pub cancelled_count: i64,
2470    /// The denominator of the success rate.
2471    pub total_count: i64,
2472}
2473
2474pub async fn count_terminal_outcomes_since(
2475    conn: &mut PgConnection,
2476    since: DateTime<Utc>,
2477) -> ModelResult<TerminalOutcomeTotals> {
2478    let res = sqlx::query_as!(
2479        TerminalOutcomeTotals,
2480        r#"
2481SELECT COUNT(*) FILTER (
2482    WHERE state = ANY($2::credit_registration_state [])
2483  ) AS "success_count!",
2484  COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
2485  COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_permanent_count!",
2486  COUNT(*) FILTER (WHERE state = 'cancelled') AS "cancelled_count!",
2487  COUNT(*) AS "total_count!"
2488FROM credit_registrations
2489WHERE terminal_at >= $1
2490  AND superseded_by_id IS NULL
2491  AND deleted_at IS NULL
2492        "#,
2493        since,
2494        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
2495    )
2496    .fetch_one(conn)
2497    .await?;
2498    Ok(res)
2499}
2500
2501/// Live rows that entered one state within the window. `misregistered` is not terminal, so
2502/// `terminal_at` cannot answer this.
2503pub async fn count_entered_state_since(
2504    conn: &mut PgConnection,
2505    state: CreditRegistrationState,
2506    since: DateTime<Utc>,
2507) -> ModelResult<i64> {
2508    let count = sqlx::query_scalar!(
2509        r#"
2510SELECT COUNT(*) AS "count!"
2511FROM credit_registrations
2512WHERE state = $1
2513  AND state_entered_at >= $2
2514  AND superseded_by_id IS NULL
2515  AND deleted_at IS NULL
2516        "#,
2517        state as CreditRegistrationState,
2518        since,
2519    )
2520    .fetch_one(conn)
2521    .await?;
2522    Ok(count)
2523}
2524
2525/// How long registration took, in seconds, for rows that reached `registered` in a window.
2526#[derive(Debug, Clone, PartialEq)]
2527pub struct RegistrationLatency {
2528    pub registered_count: i64,
2529    /// `terminal_at - created_at`: the student's wait, most of which is theirs to end.
2530    pub p50_end_to_end_secs: Option<i64>,
2531    pub p95_end_to_end_secs: Option<i64>,
2532    /// `registered_at - submitted_at`: how long the study registry took, which is the number to
2533    /// quote at them.
2534    pub p50_confirmation_secs: Option<i64>,
2535    pub p95_confirmation_secs: Option<i64>,
2536}
2537
2538pub async fn get_registration_latency_between(
2539    conn: &mut PgConnection,
2540    from: DateTime<Utc>,
2541    to: DateTime<Utc>,
2542) -> ModelResult<RegistrationLatency> {
2543    let res = sqlx::query_as!(
2544        RegistrationLatency,
2545        r#"
2546SELECT COUNT(*) AS "registered_count!",
2547  CEIL(
2548    EXTRACT(
2549      EPOCH
2550      FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
2551          ORDER BY terminal_at - created_at
2552        )
2553    )
2554  )::bigint AS "p50_end_to_end_secs",
2555  CEIL(
2556    EXTRACT(
2557      EPOCH
2558      FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
2559          ORDER BY terminal_at - created_at
2560        )
2561    )
2562  )::bigint AS "p95_end_to_end_secs",
2563  CEIL(
2564    EXTRACT(
2565      EPOCH
2566      FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
2567          ORDER BY registered_at - submitted_at
2568        )
2569    )
2570  )::bigint AS "p50_confirmation_secs",
2571  CEIL(
2572    EXTRACT(
2573      EPOCH
2574      FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
2575          ORDER BY registered_at - submitted_at
2576        )
2577    )
2578  )::bigint AS "p95_confirmation_secs"
2579FROM credit_registrations
2580WHERE state = 'registered'
2581  AND terminal_at >= $1
2582  AND terminal_at < $2
2583  AND superseded_by_id IS NULL
2584  AND deleted_at IS NULL
2585        "#,
2586        from,
2587        to,
2588    )
2589    .fetch_one(conn)
2590    .await?;
2591    Ok(res)
2592}
2593
2594/// Live volumes per course module, for the Courses tab's one row per module.
2595#[derive(Debug, Clone, PartialEq)]
2596pub struct ModuleRegistrationTotals {
2597    pub course_module_id: Uuid,
2598    pub total_count: i64,
2599    pub success_count: i64,
2600    pub in_flight_count: i64,
2601    pub failed_count: i64,
2602    pub needs_admin_attention_count: i64,
2603    pub last_registered_at: Option<DateTime<Utc>>,
2604    /// The code most of the module's failing rows carry, which is usually the whole diagnosis.
2605    pub top_error_code: Option<CreditRegistrationErrorCode>,
2606}
2607
2608/// `failed_count` is `failed_permanent` and `misregistered` only, so the columns do not add up to
2609/// the total by design.
2610pub async fn count_by_module(
2611    conn: &mut PgConnection,
2612) -> ModelResult<Vec<ModuleRegistrationTotals>> {
2613    let res = sqlx::query_as!(
2614        ModuleRegistrationTotals,
2615        r#"
2616SELECT cr.course_module_id,
2617  COUNT(*) AS "total_count!",
2618  COUNT(*) FILTER (
2619    WHERE cr.state = ANY($1::credit_registration_state [])
2620  ) AS "success_count!",
2621  COUNT(*) FILTER (WHERE cr.terminal_at IS NULL) AS "in_flight_count!",
2622  COUNT(*) FILTER (
2623    WHERE cr.state = ANY($2::credit_registration_state [])
2624  ) AS "failed_count!",
2625  COUNT(*) FILTER (WHERE cr.needs_admin_attention) AS "needs_admin_attention_count!",
2626  MAX(cr.registered_at) AS "last_registered_at",
2627  (
2628    SELECT inner_cr.error_code
2629    FROM credit_registrations inner_cr
2630    WHERE inner_cr.course_module_id = cr.course_module_id
2631      AND inner_cr.error_code IS NOT NULL
2632      AND inner_cr.superseded_by_id IS NULL
2633      AND inner_cr.deleted_at IS NULL
2634    GROUP BY inner_cr.error_code
2635    ORDER BY COUNT(*) DESC,
2636      inner_cr.error_code
2637    LIMIT 1
2638  ) AS "top_error_code?: CreditRegistrationErrorCode"
2639FROM credit_registrations cr
2640WHERE superseded_by_id IS NULL
2641  AND deleted_at IS NULL
2642GROUP BY cr.course_module_id
2643        "#,
2644        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
2645        &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
2646    )
2647    .fetch_all(conn)
2648    .await?;
2649    Ok(res)
2650}
2651
2652/// One row the Errors tab wants a human to look at, with the detectors that picked it.
2653#[derive(Debug, Clone, PartialEq)]
2654pub struct AttentionRegistration {
2655    pub id: Uuid,
2656    pub user_id: Uuid,
2657    pub first_name: Option<String>,
2658    pub last_name: Option<String>,
2659    pub email: Option<String>,
2660    pub course_id: Uuid,
2661    pub course_name: String,
2662    pub course_module_id: Uuid,
2663    pub course_module_name: Option<String>,
2664    pub state: CreditRegistrationState,
2665    pub state_entered_at: DateTime<Utc>,
2666    pub error_code: Option<CreditRegistrationErrorCode>,
2667    pub attempt_count: i32,
2668    pub needs_admin_attention: bool,
2669    pub next_attempt_at: DateTime<Utc>,
2670    pub student_number: Option<String>,
2671    pub stuck_in_state: bool,
2672    pub permanent_error: bool,
2673    pub retry_window_expired: bool,
2674    pub misregistered: bool,
2675    pub too_many_attempts: bool,
2676    pub outcome_uncertain: bool,
2677    pub flagged_by_pipeline: bool,
2678}
2679
2680/// Rows at least one attention detector picked, worst-waiting first.
2681///
2682/// Superseded rows are excluded in the query rather than left to a predicate elsewhere: a false
2683/// positive here costs an operator's attention directly.
2684/// `thresholds` are the same seconds [`count_stuck`] uses, so the table and the alert cannot
2685/// disagree about what stuck means.
2686pub async fn get_attention_items(
2687    conn: &mut PgConnection,
2688    thresholds: &StuckThresholds,
2689    too_many_attempts: i32,
2690    limit: i64,
2691) -> ModelResult<Vec<AttentionRegistration>> {
2692    let (state_thresholds, threshold_secs) = thresholds.state_seconds_arrays();
2693    let res = sqlx::query_as!(
2694        AttentionRegistration,
2695        r#"
2696SELECT cr.id,
2697  cr.user_id,
2698  ud.first_name AS "first_name?",
2699  ud.last_name AS "last_name?",
2700  ud.email AS "email?",
2701  cr.course_id,
2702  c.name AS course_name,
2703  cr.course_module_id,
2704  cm.name AS course_module_name,
2705  cr.state,
2706  cr.state_entered_at,
2707  cr.error_code AS "error_code?",
2708  cr.submit_retry_count + cr.verify_attempt_count AS "attempt_count!",
2709  cr.needs_admin_attention,
2710  cr.next_attempt_at,
2711  cr.student_number,
2712  d.stuck_in_state AS "stuck_in_state!",
2713  d.permanent_error AS "permanent_error!",
2714  d.retry_window_expired AS "retry_window_expired!",
2715  d.misregistered AS "misregistered!",
2716  d.too_many_attempts AS "too_many_attempts!",
2717  d.outcome_uncertain AS "outcome_uncertain!",
2718  d.flagged_by_pipeline AS "flagged_by_pipeline!"
2719FROM credit_registrations cr
2720  JOIN courses c ON c.id = cr.course_id
2721  JOIN course_modules cm ON cm.id = cr.course_module_id
2722  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
2723  LEFT JOIN LATERAL (
2724    SELECT u.threshold_secs
2725    FROM UNNEST($1::credit_registration_state [], $2::double precision []) AS u(state, threshold_secs)
2726    WHERE u.state = cr.state
2727  ) t ON TRUE
2728  CROSS JOIN LATERAL (
2729    SELECT cr.terminal_at IS NULL
2730      AND t.threshold_secs IS NOT NULL
2731      AND now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs) AS stuck_in_state,
2732      cr.state = 'failed_permanent'
2733      AND cr.needs_admin_attention AS permanent_error,
2734      -- Coalesced because error_code is nullable and this is selected into a plain `bool`: a row
2735      -- another detector picked while holding no error code would otherwise fail to decode and take
2736      -- the whole table down with it.
2737      COALESCE(cr.error_code = 'retry_window_expired', FALSE) AS retry_window_expired,
2738      cr.state = 'misregistered' AS misregistered,
2739      cr.submit_retry_count + cr.verify_attempt_count >= $3 AS too_many_attempts,
2740      cr.state = 'submission_uncertain' AS outcome_uncertain,
2741      cr.needs_admin_attention AS flagged_by_pipeline
2742  ) d
2743WHERE cr.superseded_by_id IS NULL
2744  AND cr.deleted_at IS NULL
2745  AND (
2746    d.stuck_in_state
2747    OR d.permanent_error
2748    OR d.retry_window_expired
2749    OR d.misregistered
2750    OR d.too_many_attempts
2751    OR d.outcome_uncertain
2752    OR d.flagged_by_pipeline
2753  )
2754ORDER BY cr.state_entered_at
2755LIMIT $4
2756        "#,
2757        &state_thresholds as &[CreditRegistrationState],
2758        &threshold_secs as &[f64],
2759        too_many_attempts,
2760        limit,
2761    )
2762    .fetch_all(conn)
2763    .await?;
2764    Ok(res)
2765}
2766
2767/// Live rows in each of the given states, newest activity first within each state, for the
2768/// Reconciliation lists. `limit_per_state` caps every state independently, via `ROW_NUMBER`, so one
2769/// state with many rows cannot crowd another out of a shared `LIMIT`.
2770pub async fn get_live_by_states(
2771    conn: &mut PgConnection,
2772    states: &[CreditRegistrationState],
2773    limit_per_state: i64,
2774) -> ModelResult<Vec<CreditRegistration>> {
2775    let res = sqlx::query_as!(
2776        CreditRegistration,
2777        r#"
2778SELECT cr.*
2779FROM credit_registrations cr
2780  JOIN (
2781    SELECT id,
2782      ROW_NUMBER() OVER (
2783        PARTITION BY state
2784        ORDER BY state_entered_at DESC
2785      ) AS rn
2786    FROM credit_registrations
2787    WHERE state = ANY($1)
2788      AND superseded_by_id IS NULL
2789      AND deleted_at IS NULL
2790  ) ranked ON ranked.id = cr.id
2791WHERE ranked.rn <= $2
2792ORDER BY cr.state_entered_at DESC
2793        "#,
2794        states as &[CreditRegistrationState],
2795        limit_per_state,
2796    )
2797    .fetch_all(conn)
2798    .await?;
2799    Ok(res)
2800}
2801
2802/// Makes every due-later `failed_retryable` row due now; returns how many. The button pressed once
2803/// the study registry says an outage is over.
2804///
2805/// Only `failed_retryable`: no other state's backoff means "waiting out an outage", and
2806/// `submission_uncertain` must never be swept forward in bulk.
2807pub async fn requeue_retryable_now(
2808    conn: &mut PgConnection,
2809    course_id: Option<Uuid>,
2810    course_module_id: Option<Uuid>,
2811    limit: i64,
2812) -> ModelResult<i64> {
2813    let count = sqlx::query_scalar!(
2814        r#"
2815WITH due AS (
2816  SELECT id
2817  FROM credit_registrations
2818  WHERE state = 'failed_retryable'
2819    AND next_attempt_at > now()
2820    AND superseded_by_id IS NULL
2821    AND deleted_at IS NULL
2822    AND ($2::uuid IS NULL OR course_id = $2)
2823    AND ($3::uuid IS NULL OR course_module_id = $3)
2824  ORDER BY next_attempt_at
2825  LIMIT $1
2826),
2827updated AS (
2828  UPDATE credit_registrations cr
2829  SET next_attempt_at = now()
2830  FROM due
2831  WHERE cr.id = due.id
2832  RETURNING cr.id
2833)
2834SELECT COUNT(*) AS "count!"
2835FROM updated
2836        "#,
2837        limit,
2838        course_id,
2839        course_module_id,
2840    )
2841    .fetch_one(conn)
2842    .await?;
2843    Ok(count)
2844}
2845
2846/// How long a row may sit in one state before it counts as stuck. Seconds, per state.
2847///
2848/// Also the wire payload the health endpoint reports as thresholds: field names are the
2849/// `stuck_*_secs` keys the frontend reads, so do not rename without updating it.
2850#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ToSchema)]
2851pub struct StuckThresholds {
2852    pub stuck_ready_to_submit_secs: i64,
2853    pub stuck_submitting_secs: i64,
2854    pub stuck_awaiting_verification_secs: i64,
2855    pub stuck_failed_retryable_secs: i64,
2856}
2857
2858impl StuckThresholds {
2859    /// The four states this covers, paired with their threshold in seconds, in a fixed order both
2860    /// `get_attention_items` and `count_stuck` bind the same way: `UNNEST`ed into a
2861    /// state -> threshold lookup rather than each carrying its own copy of the `CASE`.
2862    fn state_seconds_arrays(&self) -> ([CreditRegistrationState; 4], [f64; 4]) {
2863        (
2864            [
2865                CreditRegistrationState::ReadyToSubmit,
2866                CreditRegistrationState::Submitting,
2867                CreditRegistrationState::AwaitingVerification,
2868                CreditRegistrationState::FailedRetryable,
2869            ],
2870            [
2871                self.stuck_ready_to_submit_secs as f64,
2872                self.stuck_submitting_secs as f64,
2873                self.stuck_awaiting_verification_secs as f64,
2874                self.stuck_failed_retryable_secs as f64,
2875            ],
2876        )
2877    }
2878}
2879
2880#[derive(Debug, Clone, PartialEq)]
2881pub struct StuckRegistrationCount {
2882    pub state: CreditRegistrationState,
2883    pub count: i64,
2884    /// Over three times the threshold, which is what makes the alert critical.
2885    pub severely_stuck_count: i64,
2886    pub oldest_state_entered_at: Option<DateTime<Utc>>,
2887}
2888
2889/// Rows the pipeline should have moved by now, per state. Only the four states with a threshold
2890/// count: the rest wait on a student or a human, where an alert would fire on normal operation.
2891pub async fn count_stuck(
2892    conn: &mut PgConnection,
2893    thresholds: &StuckThresholds,
2894) -> ModelResult<Vec<StuckRegistrationCount>> {
2895    let (state_thresholds, threshold_secs) = thresholds.state_seconds_arrays();
2896    let rows = sqlx::query_as!(
2897        StuckRegistrationCount,
2898        r#"
2899SELECT cr.state AS "state!",
2900  COUNT(*) AS "count!",
2901  COUNT(*) FILTER (
2902    WHERE now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs * 3)
2903  ) AS "severely_stuck_count!",
2904  MIN(cr.state_entered_at) AS "oldest_state_entered_at"
2905FROM credit_registrations cr
2906  JOIN UNNEST($1::credit_registration_state [], $2::double precision []) AS t(state, threshold_secs) ON t.state = cr.state
2907WHERE cr.terminal_at IS NULL
2908  AND cr.superseded_by_id IS NULL
2909  AND cr.deleted_at IS NULL
2910  AND now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs)
2911GROUP BY cr.state
2912        "#,
2913        &state_thresholds as &[CreditRegistrationState],
2914        &threshold_secs as &[f64],
2915    )
2916    .fetch_all(conn)
2917    .await?;
2918    Ok(rows)
2919}
2920
2921#[cfg(test)]
2922mod tests {
2923    use super::*;
2924    use crate::course_module_completions::{
2925        CourseModuleCompletionGranter, NewCourseModuleCompletion,
2926    };
2927    use crate::credit_registration_events::CreditRegistrationEventKind;
2928    use crate::test_helper::*;
2929
2930    /// Checked over every pair the table allows rather than read off each arm: these are the
2931    /// properties the pipeline is built on, and an edge added in the wrong arm breaks one of them
2932    /// while still looking plausible where it was written.
2933    #[test]
2934    fn the_edge_table_keeps_the_machines_invariants() {
2935        use CreditRegistrationState as State;
2936        let import_claims = [State::CheckingEnrolment, State::Submitting];
2937        for from in State::ALL {
2938            if from.is_terminal() || from == State::Misregistered {
2939                assert!(
2940                    from.allowed_targets().is_empty(),
2941                    "{from:?} is not the pipeline's to move"
2942                );
2943            }
2944            for &to in from.allowed_targets() {
2945                assert_ne!(from, to, "{from:?}: staying put is not an edge");
2946                if matches!(
2947                    from,
2948                    State::Submitting | State::SubmissionUncertain | State::AwaitingVerification
2949                ) {
2950                    assert!(
2951                        !import_claims.contains(&to),
2952                        "{from:?} -> {to:?} would let a second request out for a submission the \
2953                         study registry may already hold"
2954                    );
2955                }
2956                if to == State::Submitting {
2957                    assert_eq!(from, State::CheckingEnrolment, "only import may submit");
2958                }
2959                if to == State::Registered {
2960                    assert!(
2961                        matches!(
2962                            from,
2963                            State::Submitting
2964                                | State::AwaitingVerification
2965                                | State::SubmissionUncertain
2966                        ),
2967                        "{from:?} -> registered: only an answer about a sent submission registers a \
2968                         row"
2969                    );
2970                }
2971            }
2972        }
2973    }
2974
2975    #[test]
2976    fn success_states_const_matches_is_success() {
2977        let from_const: Vec<CreditRegistrationState> =
2978            CreditRegistrationState::SUCCESS_STATES.to_vec();
2979        let from_predicate: Vec<CreditRegistrationState> = CreditRegistrationState::ALL
2980            .into_iter()
2981            .filter(|state| state.is_success())
2982            .collect();
2983        assert_eq!(from_const, from_predicate);
2984    }
2985
2986    async fn insert_registration(
2987        conn: &mut PgConnection,
2988        user: Uuid,
2989        course: Uuid,
2990        course_instance: Uuid,
2991        course_module: Uuid,
2992    ) -> Uuid {
2993        let completion = crate::course_module_completions::insert(
2994            conn,
2995            PKeyPolicy::Generate,
2996            &NewCourseModuleCompletion {
2997                course_id: course,
2998                course_module_id: course_module,
2999                user_id: user,
3000                completion_date: Utc::now(),
3001                completion_registration_attempt_date: None,
3002                completion_language: "en".to_string(),
3003                eligible_for_ects: true,
3004                email: "student@example.com".to_string(),
3005                grade: Some(4),
3006                passed: true,
3007            },
3008            CourseModuleCompletionGranter::Automatic,
3009        )
3010        .await
3011        .unwrap();
3012
3013        insert(
3014            conn,
3015            PKeyPolicy::Generate,
3016            &NewCreditRegistration {
3017                course_module_completion_id: completion.id,
3018                user_id: user,
3019                course_id: course,
3020                course_module_id: course_module,
3021                course_instance_id: course_instance,
3022                attempt_number: 1,
3023            },
3024            None,
3025        )
3026        .await
3027        .unwrap()
3028    }
3029
3030    #[tokio::test]
3031    async fn transition_stamps_state_entered_at_and_writes_an_event() {
3032        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3033        let id =
3034            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3035        let before = get_by_id(tx.as_mut(), id).await.unwrap();
3036
3037        let after = transition(
3038            tx.as_mut(),
3039            id,
3040            &Transition::planted(CreditRegistrationState::ReadyToSubmit),
3041        )
3042        .await
3043        .unwrap();
3044
3045        assert_eq!(after.state, CreditRegistrationState::ReadyToSubmit);
3046        assert!(after.state_entered_at > before.state_entered_at);
3047
3048        let events = crate::credit_registration_events::get_by_registration_id(tx.as_mut(), id)
3049            .await
3050            .unwrap();
3051        // The `created` event from insert plus this state change, newest first.
3052        assert_eq!(events.len(), 2);
3053        assert_eq!(events[1].kind, CreditRegistrationEventKind::Created);
3054        assert_eq!(events[0].kind, CreditRegistrationEventKind::StateChanged);
3055        assert_eq!(events[0].from_state, Some(CreditRegistrationState::Pending));
3056        assert_eq!(
3057            events[0].to_state,
3058            Some(CreditRegistrationState::ReadyToSubmit)
3059        );
3060    }
3061
3062    #[tokio::test]
3063    async fn consecutive_state_changes_stay_ordered_inside_one_transaction() {
3064        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3065        let id =
3066            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3067
3068        let first = transition(
3069            tx.as_mut(),
3070            id,
3071            &Transition::planted(CreditRegistrationState::ReadyToSubmit),
3072        )
3073        .await
3074        .unwrap();
3075        let second = transition(
3076            tx.as_mut(),
3077            id,
3078            &Transition::planted(CreditRegistrationState::CheckingEnrolment),
3079        )
3080        .await
3081        .unwrap();
3082        assert!(second.state_entered_at > first.state_entered_at);
3083    }
3084
3085    #[tokio::test]
3086    async fn transition_stamps_lifecycle_timestamps() {
3087        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3088        let id =
3089            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3090
3091        let checking = transition(
3092            tx.as_mut(),
3093            id,
3094            &Transition::planted(CreditRegistrationState::CheckingEnrolment),
3095        )
3096        .await
3097        .unwrap();
3098        assert!(checking.enrolment_checked_at.is_none());
3099        assert!(checking.submitted_at.is_none());
3100        assert!(checking.terminal_at.is_none());
3101
3102        // Leaving checking_enrolment is what stamps enrolment_checked_at.
3103        let submitting = transition(
3104            tx.as_mut(),
3105            id,
3106            &Transition::planted(CreditRegistrationState::Submitting),
3107        )
3108        .await
3109        .unwrap();
3110        assert!(submitting.enrolment_checked_at.is_some());
3111        assert!(submitting.submitted_at.is_some());
3112        assert!(submitting.registered_at.is_none());
3113        assert!(submitting.terminal_at.is_none());
3114
3115        let registered = transition(
3116            tx.as_mut(),
3117            id,
3118            &Transition::planted(CreditRegistrationState::Registered),
3119        )
3120        .await
3121        .unwrap();
3122        assert!(registered.registered_at.is_some());
3123        assert!(registered.terminal_at.is_some());
3124        assert_eq!(registered.submitted_at, submitting.submitted_at);
3125        assert_eq!(
3126            registered.enrolment_checked_at,
3127            submitting.enrolment_checked_at
3128        );
3129    }
3130
3131    #[tokio::test]
3132    async fn terminal_at_holds_between_terminal_states_and_clears_on_a_retry() {
3133        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3134        let id =
3135            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3136
3137        let first = transition(
3138            tx.as_mut(),
3139            id,
3140            &Transition::planted(CreditRegistrationState::Cancelled),
3141        )
3142        .await
3143        .unwrap();
3144        let terminal_at = first.terminal_at.unwrap();
3145
3146        let second = transition(
3147            tx.as_mut(),
3148            id,
3149            &Transition::planted(CreditRegistrationState::FailedPermanent),
3150        )
3151        .await
3152        .unwrap();
3153        assert_eq!(second.terminal_at, Some(terminal_at));
3154
3155        let retried = transition(
3156            tx.as_mut(),
3157            id,
3158            &Transition::planted(CreditRegistrationState::ReadyToSubmit),
3159        )
3160        .await
3161        .unwrap();
3162        assert_eq!(retried.terminal_at, None);
3163    }
3164
3165    #[tokio::test]
3166    async fn entering_no_usable_enrolment_clears_a_dismissed_banner() {
3167        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3168        let id =
3169            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3170
3171        transition(
3172            tx.as_mut(),
3173            id,
3174            &Transition::planted(CreditRegistrationState::NoUsableEnrolment),
3175        )
3176        .await
3177        .unwrap();
3178        dismiss_enrolment_banner(tx.as_mut(), id, user)
3179            .await
3180            .unwrap();
3181        assert!(
3182            get_by_id(tx.as_mut(), id)
3183                .await
3184                .unwrap()
3185                .enrolment_banner_dismissed_at
3186                .is_some()
3187        );
3188
3189        transition(
3190            tx.as_mut(),
3191            id,
3192            &Transition::planted(CreditRegistrationState::ReadyToSubmit),
3193        )
3194        .await
3195        .unwrap();
3196        // Still dismissed: only a fresh enrolment problem brings the banner back.
3197        assert!(
3198            get_by_id(tx.as_mut(), id)
3199                .await
3200                .unwrap()
3201                .enrolment_banner_dismissed_at
3202                .is_some()
3203        );
3204
3205        let back = transition(
3206            tx.as_mut(),
3207            id,
3208            &Transition::planted(CreditRegistrationState::NoUsableEnrolment),
3209        )
3210        .await
3211        .unwrap();
3212        assert_eq!(back.enrolment_banner_dismissed_at, None);
3213    }
3214
3215    #[tokio::test]
3216    async fn transition_carries_the_error_and_leaves_the_admin_flag_alone_unless_asked() {
3217        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3218        let id =
3219            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3220        set_needs_admin_attention(tx.as_mut(), id, true)
3221            .await
3222            .unwrap();
3223
3224        let failed = transition(
3225            tx.as_mut(),
3226            id,
3227            &Transition {
3228                error_code: Some(CreditRegistrationErrorCode::EnrolmentNotFound),
3229                error_message: Some("no accepted enrolment".to_string()),
3230                ..Transition::planted(CreditRegistrationState::FailedPermanent)
3231            },
3232        )
3233        .await
3234        .unwrap();
3235        assert_eq!(
3236            failed.error_code,
3237            Some(CreditRegistrationErrorCode::EnrolmentNotFound)
3238        );
3239        assert!(failed.needs_admin_attention);
3240
3241        let resolved = transition(
3242            tx.as_mut(),
3243            id,
3244            &Transition {
3245                needs_admin_attention: Some(false),
3246                ..Transition::planted(CreditRegistrationState::ReadyToSubmit)
3247            },
3248        )
3249        .await
3250        .unwrap();
3251        assert!(!resolved.needs_admin_attention);
3252        assert_eq!(resolved.error_code, None);
3253    }
3254
3255    #[tokio::test]
3256    async fn a_transition_expecting_a_stale_prior_state_is_refused() {
3257        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
3258        let id =
3259            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
3260
3261        transition(
3262            tx.as_mut(),
3263            id,
3264            &Transition::planted(CreditRegistrationState::Blocked),
3265        )
3266        .await
3267        .unwrap();
3268
3269        // As if a caller had claimed the row into `resolving_enrolment` and, after an await, is
3270        // writing back based on that now-stale snapshot: the row moved to `blocked` in between.
3271        let refused = transition(
3272            tx.as_mut(),
3273            id,
3274            &Transition {
3275                expected_from_state: Some(CreditRegistrationState::ResolvingEnrolment),
3276                ..Transition::planted(CreditRegistrationState::CheckingEnrolment)
3277            },
3278        )
3279        .await;
3280        assert!(refused.is_err());
3281        assert_eq!(
3282            get_by_id(tx.as_mut(), id).await.unwrap().state,
3283            CreditRegistrationState::Blocked
3284        );
3285    }
3286}