Skip to main content

headless_lms_credit_registration/workflow/
decision.rs

1//! What one answer does to its row, and writing it there, guarded by the state the claim expects.
2
3use chrono::{DateTime, Utc};
4use headless_lms_models::credit_registration_events::CreditRegistrationEventKind;
5use headless_lms_models::credit_registrations::{
6    self, CreditRegistrationState, PayloadSnapshot, Transition, Transitioned,
7    set_resubmit_not_before, set_sisu_attainment_if_unclaimed, set_submitted_attainment,
8};
9use headless_lms_models::library::credit_registration::enrolment_checks::{
10    self, EnrolmentCheckAnswer, record_enrolment_check,
11};
12use headless_lms_models::library::credit_registration::grade_mapping::MappedGrade;
13use headless_lms_models::library::credit_registration::outcomes::{
14    NextAttempt, Outcome, UnaskedMove,
15};
16use headless_lms_models::library::credit_registration::scrub::{
17    scrub_text, suotar_exchange_details,
18};
19use headless_lms_models::library::credit_registration::study_registry::RegistryAttainment;
20use headless_lms_models::verified_student_numbers;
21use secrecy::ExposeSecret;
22use sqlx::{Connection, PgConnection, Postgres, Transaction};
23use uuid::Uuid;
24
25use super::claim::ClaimedRegistration;
26use crate::error::CreditRegistrationResult;
27use crate::registry::{ExchangeAudit, StudentNumber, SubmittedAttainmentRef};
28
29/// What one answer does to its row: the move, the timeline line and error that go with it, and the
30/// writes it asks for, grouped by whether they outlive a lost race. The exchange behind the answer
31/// is the caller's [`crate::registry::ExchangeAudit`], not part of the decision.
32pub(crate) struct Decision<'a> {
33    pub outcome: Outcome,
34    /// The timeline line for the move.
35    pub message: Option<String>,
36    /// The item's own error, or a refused request's, persisted on the row once scrubbed.
37    pub row_error: Option<&'a str>,
38    pub pre_transition: PreTransitionChanges<'a>,
39    pub atomic: AtomicChanges<'a>,
40}
41
42impl<'a> Decision<'a> {
43    pub(crate) fn new(outcome: Outcome) -> Self {
44        Self {
45            outcome,
46            message: None,
47            row_error: None,
48            pre_transition: PreTransitionChanges::default(),
49            atomic: AtomicChanges::default(),
50        }
51    }
52
53    pub(crate) fn with_message(self, message: impl Into<String>) -> Self {
54        Self {
55            message: Some(message.into()),
56            ..self
57        }
58    }
59
60    pub(crate) fn with_row_error(self, row_error: Option<&'a str>) -> Self {
61        Self { row_error, ..self }
62    }
63
64    /// The submission an import answer named, and its attainment type.
65    pub(crate) fn with_submitted_attainment(
66        mut self,
67        submission: Option<&'a SubmittedAttainmentRef>,
68    ) -> Self {
69        self.pre_transition.submitted_attainment = submission;
70        self
71    }
72
73    /// The Sisu attainment that settles the row.
74    pub(crate) fn with_sisu_attainment(
75        mut self,
76        attainment: Option<&'a RegistryAttainment>,
77    ) -> Self {
78        self.pre_transition.sisu_attainment = attainment;
79        self
80    }
81
82    /// Suotar's own bound on when resending becomes safe.
83    pub(crate) fn with_resubmit_not_before(
84        mut self,
85        resubmit_not_before: Option<DateTime<Utc>>,
86    ) -> Self {
87        self.pre_transition.resubmit_not_before = resubmit_not_before;
88        self
89    }
90
91    pub(crate) fn with_payload(mut self, payload: PayloadChange) -> Self {
92        self.atomic.payload = Some(payload);
93        self
94    }
95
96    pub(crate) fn with_enrolment_check(mut self, check: Option<EnrolmentCheckAnswer<'a>>) -> Self {
97        self.atomic.enrolment_check = check;
98        self
99    }
100}
101
102/// Written before the guarded transition and kept even when the row moved on meanwhile: each
103/// records something the registry already did or said.
104#[derive(Default)]
105pub(crate) struct PreTransitionChanges<'a> {
106    /// Kept on a row that moved on, so support can still find what the submission created.
107    pub submitted_attainment: Option<&'a SubmittedAttainmentRef>,
108    /// Written outside any transaction: a lost race for it surfaces as a unique violation, which
109    /// would abort the transaction, so the caller must not hold one open either.
110    pub sisu_attainment: Option<&'a RegistryAttainment>,
111    pub resubmit_not_before: Option<DateTime<Utc>>,
112}
113
114/// Written in the transition's transaction, alongside the retry count and next enrolment check
115/// the outcome asks for, so they roll back with a row that moved on.
116#[derive(Default)]
117pub(crate) struct AtomicChanges<'a> {
118    pub payload: Option<PayloadChange>,
119    /// Set for a row on its enrolment check schedule, whose check is logged with the answer.
120    pub enrolment_check: Option<EnrolmentCheckAnswer<'a>>,
121}
122
123/// What a resolved enrolment does to the payload a row carries.
124#[derive(Debug)]
125pub(crate) enum PayloadChange {
126    /// Frozen for import, replacing the registered credits of the student's other attempts once it
127    /// is registered.
128    Frozen {
129        snapshot: Box<PayloadSnapshot>,
130        supersedes: Vec<Uuid>,
131    },
132    /// Not sent, since the registry already holds the credit: see
133    /// [`headless_lms_models::credit_registrations::prepare_unsent_duplicate`].
134    Unsent { weighed_grade: Option<MappedGrade> },
135}
136
137/// What writing one answer did to its row.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub(crate) enum Applied {
140    /// `is_failure` when the row now carries an error code that is no waiting answer, which is what
141    /// `items_failed` counts; `is_waiting` when it carries one that is.
142    Written { is_failure: bool, is_waiting: bool },
143    /// Another writer moved the row since it was read, so the row is theirs and nothing was
144    /// written. The rest of the batch carries on: aborting would leave it in the state the phase's
145    /// own preflight wrote, which no phase claims again.
146    MovedOn { found: CreditRegistrationState },
147}
148
149/// Writes a decision made from the registry's answer, or its refusal, to the claimed row, with the
150/// exchange behind it on the event. Lands only if the row is still in the claim's expected state.
151pub(crate) async fn write_decision(
152    conn: &mut PgConnection,
153    claim: &ClaimedRegistration,
154    decision: Decision<'_>,
155    audit: &ExchangeAudit,
156) -> CreditRegistrationResult<Applied> {
157    let transition = Transition {
158        error_message: decision.row_error.map(scrub_text),
159        event_kind: CreditRegistrationEventKind::SuotarResponse,
160        event_message: decision.message.clone(),
161        suotar_api_call_id: audit.call_id,
162        suotar_endpoint: Some(audit.endpoint),
163        suotar_requested_at: Some(audit.requested_at),
164        suotar_answered_at: Some(audit.answered_at),
165        suotar_answer: Some(audit.answer),
166        event_details: Some(suotar_exchange_details(
167            Some(&audit.request),
168            audit.response.as_ref(),
169        )),
170        request_item_id: Some(audit.request_item_id.clone()),
171        ..decision
172            .outcome
173            .transition(Some(claim.expected_state()), Utc::now())
174    };
175    write_outcome(
176        conn,
177        claim,
178        &decision,
179        audit.sent_student_number.as_ref(),
180        &transition,
181    )
182    .await
183}
184
185/// [`write_decision`] as the last write of `tx`, committing it only if the decision landed, so
186/// everything `tx` wrote for a row that moved on meanwhile rolls back with it.
187pub(crate) async fn write_decision_committing_if_written(
188    mut tx: Transaction<'_, Postgres>,
189    claim: &ClaimedRegistration,
190    decision: Decision<'_>,
191    audit: &ExchangeAudit,
192) -> CreditRegistrationResult<Applied> {
193    let applied = write_decision(&mut tx, claim, decision, audit).await?;
194    if matches!(applied, Applied::Written { .. }) {
195        tx.commit().await?;
196    }
197    Ok(applied)
198}
199
200/// [`write_decision`] for a move made without asking the study registry, which has no exchange to
201/// record. A claim's own moves go through here too, so they are guarded like every other write.
202pub(crate) async fn write_unasked_move(
203    conn: &mut PgConnection,
204    claim: &ClaimedRegistration,
205    unasked: UnaskedMove,
206) -> CreditRegistrationResult<Applied> {
207    let transition = unasked.transition(Some(claim.expected_state()), Utc::now());
208    write_outcome(
209        conn,
210        claim,
211        &Decision::new(unasked.outcome),
212        None,
213        &transition,
214    )
215    .await
216}
217
218/// `transition` is `decision`'s move, with the event it is recorded under.
219async fn write_outcome(
220    conn: &mut PgConnection,
221    claim: &ClaimedRegistration,
222    decision: &Decision<'_>,
223    sent_student_number: Option<&StudentNumber>,
224    transition: &Transition,
225) -> CreditRegistrationResult<Applied> {
226    let registration = claim.registration();
227    let outcome = &decision.outcome;
228    let atomic = &decision.atomic;
229    write_before_transition(conn, registration.id, &decision.pre_transition).await?;
230    // Only if the request carried this number: a student who linked a working one while the request
231    // was out must not lose the link they just made.
232    if outcome.drop_verified_student_number
233        && let Some(linked) =
234            verified_student_numbers::get_by_user_id(conn, registration.user_id).await?
235        && sent_student_number.map(StudentNumber::expose)
236            == Some(linked.student_number.expose_secret())
237    {
238        verified_student_numbers::soft_delete(conn, linked.id).await?;
239    }
240    let mut tx = conn.begin().await?;
241    if outcome.increment_submit_retry_count {
242        credit_registrations::increment_submit_retry_count(&mut tx, registration.id).await?;
243    }
244    write_payload_change(&mut tx, registration.id, atomic.payload.as_ref()).await?;
245    let written =
246        credit_registrations::transition_unless_moved_on(&mut tx, registration.id, transition)
247            .await?;
248    let after = match written {
249        Transitioned::Written(after) => *after,
250        Transitioned::MovedOn { found } => return Ok(Applied::MovedOn { found }),
251    };
252    debug!(
253        credit_registration_id = %after.id,
254        from_state = ?registration.state,
255        to_state = ?after.state,
256        next_attempt_at = %after.next_attempt_at,
257        "Credit registration transitioned"
258    );
259    if outcome.next == NextAttempt::NextEnrolmentRung {
260        enrolment_checks::schedule_next_check(&mut tx, registration.id).await?;
261    }
262    record_enrolment_check(&mut tx, atomic.enrolment_check.as_ref(), &after).await?;
263    tx.commit().await?;
264    if outcome.to_state == CreditRegistrationState::SubmissionUncertain
265        && registration.state != CreditRegistrationState::SubmissionUncertain
266    {
267        warn!(
268            credit_registration_id = %registration.id,
269            "Credit registration entered submission_uncertain; Sisu's outcome could not be confirmed"
270        );
271    }
272    Ok(Applied::Written {
273        is_failure: outcome.is_failure(),
274        is_waiting: outcome.is_waiting(),
275    })
276}
277
278async fn write_before_transition(
279    conn: &mut PgConnection,
280    id: Uuid,
281    changes: &PreTransitionChanges<'_>,
282) -> CreditRegistrationResult<()> {
283    if let Some(submission) = changes.submitted_attainment {
284        set_submitted_attainment(
285            conn,
286            id,
287            submission.id.as_str(),
288            submission.attainment_type.as_deref(),
289        )
290        .await?;
291    }
292    if let Some(attainment) = changes.sisu_attainment {
293        set_sisu_attainment_if_unclaimed(
294            conn,
295            id,
296            attainment.id.as_str(),
297            Some(attainment.attainment_type.as_str()),
298        )
299        .await?;
300    }
301    if let Some(resubmit_not_before) = changes.resubmit_not_before {
302        set_resubmit_not_before(conn, id, resubmit_not_before).await?;
303    }
304    Ok(())
305}
306
307async fn write_payload_change(
308    conn: &mut PgConnection,
309    id: Uuid,
310    payload: Option<&PayloadChange>,
311) -> CreditRegistrationResult<()> {
312    match payload {
313        None => {}
314        Some(PayloadChange::Frozen {
315            snapshot,
316            supersedes,
317        }) => {
318            for &replaced in supersedes {
319                credit_registrations::mark_pending_superseded(conn, replaced, id).await?;
320            }
321            credit_registrations::set_payload_snapshot(conn, id, snapshot).await?;
322        }
323        Some(PayloadChange::Unsent { weighed_grade }) => {
324            credit_registrations::prepare_unsent_duplicate(conn, id, weighed_grade.as_ref())
325                .await?;
326        }
327    }
328    Ok(())
329}