Skip to main content

headless_lms_models/credit_registrations/
attention.rs

1//! The Needs attention queue: which live rows want a person, why, and which of them a blocking
2//! problem, a dismissal or a timing threshold alone accounts for.
3//!
4//! One query finds every row any detector picks; [`AttentionStanding`] then sorts each into
5//! exactly one section. Every count of "needs attention" (tab badge, overview, Courses column,
6//! Registrations filter, daily snapshot) is [`count_needing_attention`] or a grouping of the same
7//! rows, so none can disagree.
8
9use chrono::TimeDelta;
10use utoipa::ToSchema;
11
12use super::metrics::StuckThresholds;
13use super::state::{CreditRegistrationErrorCode, CreditRegistrationState, ResubmissionFacts};
14use crate::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
15use crate::library::credit_registration::PendingPreconditions;
16use crate::library::credit_registration::backoff::{
17    NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD, PARTIAL_REGISTRATION_ADMIN_AFTER, VERIFY_MAX_AGE,
18};
19use crate::library::credit_registration::classification::{Retryability, retryability};
20use crate::library::credit_registration::timeline::{Engagement, TimelinePosition};
21use crate::prelude::*;
22
23/// Resends after which retrying is not the answer. Verify polls do not count: confirming routinely
24/// takes many.
25pub const TOO_MANY_SUBMIT_RETRIES: i32 = 5;
26
27/// How long after an "I have enrolled" press a fetch of the code's enrolment list must have started
28/// for a missing linking email to mean the student is stuck. Above the longest press-to-list delay
29/// seen in production (56 min).
30pub const STUDENT_NUMBER_STUCK_AFTER_PRESS: TimeDelta = TimeDelta::minutes(60);
31
32/// Why a row is picked. A row can carry several.
33#[derive(
34    Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Type, ToSchema,
35)]
36#[sqlx(
37    type_name = "credit_registration_attention_reason",
38    rename_all = "snake_case"
39)]
40#[serde(rename_all = "snake_case")]
41// The API has always called it this; the short name is for Rust callers, who have the module.
42#[schema(as = CreditRegistrationAttentionReason)]
43pub enum AttentionReason {
44    /// Past its state's threshold with the pipeline still owning it. The only reason with no
45    /// person-level cause: a row carrying nothing else is running late, not needing attention.
46    StuckInState,
47    /// `failed_permanent` with the pipeline's flag up, for any code but `retry_window_expired`.
48    PermanentError,
49    RetryWindowExpired,
50    Misregistered,
51    /// Resent [`TOO_MANY_SUBMIT_RETRIES`] times and not finished.
52    TooManyAttempts,
53    /// `submission_uncertain`: never retried automatically, and never in bulk.
54    OutcomeUncertain,
55    PartlyRegisteredOverdue,
56    /// Verify has polled past its window without the course unit attainment showing up.
57    VerificationGaveUp,
58    /// Suotar has lost the submission [`NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD`] times.
59    RepeatedlyNotRegistered,
60    /// Pressed "I have enrolled", and a fetch of the code's enrolment list that could have claimed
61    /// a linking email started [`STUDENT_NUMBER_STUCK_AFTER_PRESS`] after the press, yet no linking
62    /// email has gone out on the code since, or that fetch found people who enrolled before account
63    /// linking began and were never mailed, whom the student may be among. Measured in fetches so a
64    /// paused pipeline does not count against the student.
65    StudentNumberStuck,
66    /// The pipeline's flag is up and no other reason explains it.
67    FlaggedByPipeline,
68}
69
70impl AttentionReason {
71    /// Whether the reason puts a row in the Needs attention count; see [`AttentionStanding`].
72    pub fn is_person_level(self) -> bool {
73        self != Self::StuckInState
74    }
75}
76
77/// Which section of the Needs attention tab a picked row belongs to. Exactly one per row.
78#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
79#[serde(rename_all = "snake_case")]
80pub enum AttentionStanding {
81    /// Counted in the badge.
82    NeedsAttention,
83    /// Over a timing threshold with no person-level cause; not in the badge.
84    RunningLate,
85    /// Every reason it carries is one an admin dismissed.
86    Dismissed,
87    /// A blocking problem accounts for every reason it carries, so it is listed under that problem
88    /// instead of in the badge.
89    ExplainedByProblem,
90}
91
92/// What kind of thing a [`BlockingProblem`] is, which decides what its `subject` names.
93#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
94#[serde(rename_all = "snake_case")]
95pub enum BlockingProblemKind {
96    /// A processing phase is paused or its heartbeat is late. The subject is the phase name.
97    ProcessingPhaseStopped,
98    /// The course code's enrolment list keeps failing to arrive. The subject is the course code.
99    CourseCodeFailing,
100    /// The module fails its configuration check. The subject is the course module id.
101    ModuleMisconfigured,
102}
103
104/// A problem that holds up many registrations at once.
105#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Hash, ToSchema)]
106pub struct BlockingProblem {
107    pub kind: BlockingProblemKind,
108    pub subject: String,
109}
110
111/// A processing phase that is paused or late.
112#[derive(Debug, Clone, PartialEq)]
113pub struct StoppedPhase {
114    pub phase: String,
115    /// The states whose rows wait on the phase to move.
116    pub owned_states: Vec<CreditRegistrationState>,
117    /// Whether it is one of the phases a waiting student's linking email goes out through.
118    pub sends_linking_emails: bool,
119}
120
121/// The problems currently holding up many rows at once, as the alert rules see them.
122#[derive(Debug, Clone, Default, PartialEq)]
123pub struct BlockingProblems {
124    pub stopped_phases: Vec<StoppedPhase>,
125    pub failing_course_codes: Vec<String>,
126    pub misconfigured_module_ids: Vec<Uuid>,
127}
128
129/// Whether what stopped a row carrying `error_code` is the module's configuration rather than
130/// anything support can fix.
131pub fn needs_course_setup(error_code: Option<CreditRegistrationErrorCode>) -> bool {
132    error_code.is_some_and(|code| retryability(code) == Retryability::PermanentNeedsConfig)
133}
134
135/// Everything that decides which rows are picked and where each lands.
136#[derive(Debug, Clone, PartialEq)]
137pub struct AttentionRules {
138    pub thresholds: StuckThresholds,
139    /// Only completions since then can be stuck waiting for a student number; `None` means account
140    /// linking is off and nobody can.
141    pub account_linking_since: Option<DateTime<Utc>>,
142    pub blocking: BlockingProblems,
143}
144
145/// One live row at least one detector picked.
146#[derive(Debug, Clone)]
147pub struct AttentionRegistration {
148    pub id: Uuid,
149    pub user_id: Uuid,
150    pub first_name: Option<String>,
151    pub last_name: Option<String>,
152    pub email: Option<String>,
153    pub course_id: Uuid,
154    pub course_name: String,
155    pub course_module_id: Uuid,
156    pub course_module_name: Option<String>,
157    /// The module's code now, trimmed; not the one frozen on the row.
158    pub uh_course_code: Option<String>,
159    pub course_module_completion_id: Uuid,
160    pub completion_date: DateTime<Utc>,
161    pub state: CreditRegistrationState,
162    pub state_changed_at: DateTime<Utc>,
163    pub phase_started_at: DateTime<Utc>,
164    pub error_code: Option<CreditRegistrationErrorCode>,
165    pub attempt_count: i32,
166    /// The pipeline's own flag. On its own it is only [`AttentionReason::FlaggedByPipeline`].
167    pub needs_admin_attention: bool,
168    pub next_attempt_at: DateTime<Utc>,
169    pub submitted_at: Option<DateTime<Utc>>,
170    pub resubmit_not_before: Option<DateTime<Utc>>,
171    pub student_number: Option<DbSecret>,
172    pub completion_eligible: bool,
173    pub has_verified_student_number: bool,
174    pub course_code_allowed: bool,
175    pub enrolment_resolved: bool,
176    /// The student's latest "I have enrolled" press.
177    pub pressed_at: Option<DateTime<Utc>>,
178    pub enrolment_route: Option<CreditRegistrationEnrolmentRoute>,
179    pub last_visited_at: Option<DateTime<Utc>>,
180    /// When the code's latest fetch that could claim linking mail started.
181    pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
182    /// The code's last enrolment list listed nobody.
183    pub is_enrolment_list_empty: bool,
184    pub reasons: Vec<AttentionReason>,
185    pub dismissed_reasons: Option<Vec<AttentionReason>>,
186    pub dismissed_at: Option<DateTime<Utc>>,
187    pub dismissed_by_user_id: Option<Uuid>,
188    pub dismissed_by_first_name: Option<String>,
189    pub dismissed_by_last_name: Option<String>,
190    pub dismissal_reason: Option<String>,
191}
192
193impl AttentionRegistration {
194    /// What decides whether a human may move this row; see [`ResubmissionFacts`]. The queue never
195    /// holds a superseded row.
196    pub fn resubmission_facts(&self) -> ResubmissionFacts {
197        ResubmissionFacts {
198            state: self.state,
199            is_superseded: false,
200            error_code: self.error_code,
201            resubmit_not_before: self.resubmit_not_before,
202            submitted_at: self.submitted_at,
203        }
204    }
205
206    /// Where the row stands on the admin timeline.
207    pub fn position(&self) -> TimelinePosition {
208        TimelinePosition::of(
209            self.state,
210            PendingPreconditions {
211                completion_eligible: self.completion_eligible,
212                has_verified_student_number: self.has_verified_student_number,
213                course_code_allowed: self.course_code_allowed,
214            },
215            self.enrolment_resolved,
216            Engagement::of(self.pressed_at, self.last_visited_at),
217        )
218    }
219
220    /// See [`needs_course_setup`].
221    pub fn needs_course_setup(&self) -> bool {
222        needs_course_setup(self.error_code)
223    }
224
225    /// The problem that accounts for every reason this row carries, if one does.
226    pub fn blocking_problem(&self, problems: &BlockingProblems) -> Option<BlockingProblem> {
227        let explains_all = |explains: &dyn Fn(AttentionReason) -> bool| {
228            !self.reasons.is_empty() && self.reasons.iter().all(|reason| explains(*reason))
229        };
230        for phase in &problems.stopped_phases {
231            let explains = |reason: AttentionReason| match reason {
232                AttentionReason::StuckInState => phase.owned_states.contains(&self.state),
233                AttentionReason::StudentNumberStuck => phase.sends_linking_emails,
234                _ => false,
235            };
236            if explains_all(&explains) {
237                return Some(BlockingProblem {
238                    kind: BlockingProblemKind::ProcessingPhaseStopped,
239                    subject: phase.phase.clone(),
240                });
241            }
242        }
243        if let Some(code) = self
244            .uh_course_code
245            .as_ref()
246            .filter(|code| problems.failing_course_codes.contains(code))
247            && explains_all(&|reason| reason == AttentionReason::StudentNumberStuck)
248        {
249            return Some(BlockingProblem {
250                kind: BlockingProblemKind::CourseCodeFailing,
251                subject: code.clone(),
252            });
253        }
254        if problems
255            .misconfigured_module_ids
256            .contains(&self.course_module_id)
257            && self.needs_course_setup()
258            && explains_all(&|reason| {
259                matches!(
260                    reason,
261                    AttentionReason::PermanentError | AttentionReason::FlaggedByPipeline
262                )
263            })
264        {
265            return Some(BlockingProblem {
266                kind: BlockingProblemKind::ModuleMisconfigured,
267                subject: self.course_module_id.to_string(),
268            });
269        }
270        None
271    }
272
273    /// A dismissal holds while every reason the row carries is one it covered.
274    pub fn is_dismissed(&self) -> bool {
275        self.dismissed_reasons
276            .as_ref()
277            .is_some_and(|dismissed| self.reasons.iter().all(|reason| dismissed.contains(reason)))
278    }
279
280    /// Which Needs attention section the row is listed in under `problems`.
281    pub fn standing(&self, problems: &BlockingProblems) -> AttentionStanding {
282        if self.is_dismissed() {
283            AttentionStanding::Dismissed
284        } else if self.blocking_problem(problems).is_some() {
285            AttentionStanding::ExplainedByProblem
286        } else if self.reasons.iter().any(|reason| reason.is_person_level()) {
287            AttentionStanding::NeedsAttention
288        } else {
289            AttentionStanding::RunningLate
290        }
291    }
292}
293
294/// Every live row at least one detector picks, oldest phase start first, narrowed to `only_ids`
295/// when given. Superseded rows are outside every detector: acting on a replaced attempt is never
296/// right.
297pub async fn get_attention_items(
298    conn: &mut PgConnection,
299    rules: &AttentionRules,
300    only_ids: Option<&[Uuid]>,
301) -> ModelResult<Vec<AttentionRegistration>> {
302    let (state_thresholds, threshold_secs) = rules.thresholds.state_seconds_arrays();
303    let res = sqlx::query_as!(
304        AttentionRegistration,
305        r#"
306SELECT cr.id,
307  cr.user_id,
308  ud.first_name AS "first_name?",
309  ud.last_name AS "last_name?",
310  ud.email AS "email?",
311  cr.course_id,
312  c.name AS course_name,
313  cr.course_module_id,
314  cm.name AS course_module_name,
315  NULLIF(TRIM(cm.uh_course_code), '') AS uh_course_code,
316  cr.course_module_completion_id,
317  cmc.completion_date,
318  cr.state,
319  cr.state_changed_at,
320  cr.phase_started_at,
321  cr.error_code AS "error_code?",
322  cr.submit_retry_count + cr.verify_attempt_count AS "attempt_count!",
323  cr.needs_admin_attention,
324  cr.next_attempt_at,
325  cr.submitted_at,
326  cr.resubmit_not_before,
327  cr.student_number,
328  p.completion_eligible AS "completion_eligible!",
329  p.has_verified_student_number AS "has_verified_student_number!",
330  p.course_code_allowed AS "course_code_allowed!",
331  cr.selected_enrolment_id IS NOT NULL AS "enrolment_resolved!",
332  route.enrolment_confirmed_at AS "pressed_at?",
333  route.route AS "enrolment_route?: CreditRegistrationEnrolmentRoute",
334  sig.last_visited_at AS "last_visited_at?",
335  s.last_mailing_fetch_started_at AS "last_mailing_fetch_started_at?",
336  COALESCE(s.last_listed_person_count = 0, FALSE) AS "is_enrolment_list_empty!",
337  ARRAY_REMOVE(
338    ARRAY [
339      CASE WHEN d.stuck_in_state THEN 'stuck_in_state'::credit_registration_attention_reason END,
340      CASE WHEN d.permanent_error THEN 'permanent_error'::credit_registration_attention_reason END,
341      CASE WHEN d.retry_window_expired THEN 'retry_window_expired'::credit_registration_attention_reason END,
342      CASE WHEN d.misregistered THEN 'misregistered'::credit_registration_attention_reason END,
343      CASE WHEN d.too_many_attempts THEN 'too_many_attempts'::credit_registration_attention_reason END,
344      CASE WHEN d.outcome_uncertain THEN 'outcome_uncertain'::credit_registration_attention_reason END,
345      CASE WHEN d.partly_registered_overdue THEN 'partly_registered_overdue'::credit_registration_attention_reason END,
346      CASE WHEN d.verification_gave_up THEN 'verification_gave_up'::credit_registration_attention_reason END,
347      CASE WHEN d.repeatedly_not_registered THEN 'repeatedly_not_registered'::credit_registration_attention_reason END,
348      CASE WHEN d.student_number_stuck THEN 'student_number_stuck'::credit_registration_attention_reason END,
349      CASE WHEN flagged.only_flag THEN 'flagged_by_pipeline'::credit_registration_attention_reason END
350    ],
351    NULL
352  ) AS "reasons!: Vec<AttentionReason>",
353  dis.dismissed_reasons AS "dismissed_reasons?: Vec<AttentionReason>",
354  dis.created_at AS "dismissed_at?",
355  dis.dismissed_by_user_id AS "dismissed_by_user_id?",
356  dismisser.first_name AS "dismissed_by_first_name?",
357  dismisser.last_name AS "dismissed_by_last_name?",
358  dis.reason AS "dismissal_reason?"
359FROM credit_registrations cr
360  JOIN courses c ON c.id = cr.course_id
361  JOIN course_modules cm ON cm.id = cr.course_module_id
362  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
363  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
364  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
365  LEFT JOIN credit_registration_enrolment_routes route ON route.course_module_completion_id = cr.course_module_completion_id
366  AND route.deleted_at IS NULL
367  LEFT JOIN credit_registration_enrolment_check_signals sig ON sig.course_module_completion_id = cr.course_module_completion_id
368  AND sig.deleted_at IS NULL
369  LEFT JOIN credit_registration_roster_schedules s ON s.course_code = TRIM(cm.uh_course_code)
370  LEFT JOIN credit_registration_attention_dismissals dis ON dis.credit_registration_id = cr.id
371  AND dis.deleted_at IS NULL
372  LEFT JOIN user_details dismisser ON dismisser.user_id = dis.dismissed_by_user_id
373  LEFT JOIN LATERAL (
374    SELECT u.threshold_secs
375    FROM UNNEST($1::credit_registration_state [], $2::double precision []) AS u(state, threshold_secs)
376    WHERE u.state = cr.state
377  ) t ON TRUE
378  CROSS JOIN LATERAL (
379    SELECT cr.terminal_at IS NULL
380      AND t.threshold_secs IS NOT NULL
381      AND now() - cr.state_changed_at > MAKE_INTERVAL(secs => t.threshold_secs) AS stuck_in_state,
382      cr.state = 'failed_permanent'
383      AND cr.needs_admin_attention
384      AND cr.error_code IS DISTINCT FROM 'retry_window_expired' AS permanent_error,
385      COALESCE(cr.error_code = 'retry_window_expired', FALSE) AS retry_window_expired,
386      cr.state = 'misregistered' AS misregistered,
387      cr.submit_retry_count >= $3
388      AND cr.terminal_at IS NULL AS too_many_attempts,
389      cr.state = 'submission_uncertain' AS outcome_uncertain,
390      COALESCE(
391        cr.state = 'partially_registered'
392        AND cr.partially_registered_at <= now() - ($4::bigint * INTERVAL '1 second'),
393        FALSE
394      ) AS partly_registered_overdue,
395      COALESCE(
396        cr.state IN ('awaiting_verification', 'partially_registered')
397        AND cr.submitted_at <= now() - ($5::bigint * INTERVAL '1 second'),
398        FALSE
399      ) AS verification_gave_up,
400      cr.not_registered_reimport_count >= $6
401      AND cr.terminal_at IS NULL AS repeatedly_not_registered,
402      COALESCE(
403        cr.state = 'pending'
404        AND p.completion_eligible
405        AND NOT p.has_verified_student_number
406        AND cmc.completion_date >= $7::timestamptz
407        AND s.last_mailing_fetch_started_at >= route.enrolment_confirmed_at + ($8::bigint * INTERVAL '1 second')
408        AND (
409          s.linking_unlinked_enrolled_before_count > 0
410          OR NOT EXISTS (
411            SELECT 1
412            FROM credit_registration_account_linking_emails e
413              JOIN course_modules code_module ON code_module.course_id = e.course_id
414              AND code_module.deleted_at IS NULL
415            WHERE TRIM(code_module.uh_course_code) = s.course_code
416              AND e.sent_at >= route.enrolment_confirmed_at
417              AND e.deleted_at IS NULL
418          )
419        ),
420        FALSE
421      ) AS student_number_stuck
422  ) d
423  CROSS JOIN LATERAL (
424    SELECT cr.needs_admin_attention
425      AND NOT (
426        d.stuck_in_state
427        OR d.permanent_error
428        OR d.retry_window_expired
429        OR d.misregistered
430        OR d.too_many_attempts
431        OR d.outcome_uncertain
432        OR d.partly_registered_overdue
433        OR d.verification_gave_up
434        OR d.repeatedly_not_registered
435        OR d.student_number_stuck
436      ) AS only_flag
437  ) flagged
438WHERE cr.superseded_by_id IS NULL
439  AND cr.deleted_at IS NULL
440  AND (
441    $9::uuid [] IS NULL
442    OR cr.id = ANY($9)
443  )
444  AND (
445    cr.needs_admin_attention
446    OR d.stuck_in_state
447    OR d.permanent_error
448    OR d.retry_window_expired
449    OR d.misregistered
450    OR d.too_many_attempts
451    OR d.outcome_uncertain
452    OR d.partly_registered_overdue
453    OR d.verification_gave_up
454    OR d.repeatedly_not_registered
455    OR d.student_number_stuck
456  )
457ORDER BY cr.phase_started_at,
458  cr.id
459        "#,
460        &state_thresholds as &[CreditRegistrationState],
461        &threshold_secs as &[f64],
462        TOO_MANY_SUBMIT_RETRIES,
463        PARTIAL_REGISTRATION_ADMIN_AFTER.num_seconds(),
464        VERIFY_MAX_AGE.num_seconds(),
465        NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD,
466        rules.account_linking_since,
467        STUDENT_NUMBER_STUCK_AFTER_PRESS.num_seconds(),
468        only_ids as Option<&[Uuid]>,
469    )
470    .fetch_all(conn)
471    .await?;
472    Ok(res)
473}
474
475/// The canonical Needs attention count: what the tab badge, the overview and the daily snapshot
476/// show.
477pub async fn count_needing_attention(
478    conn: &mut PgConnection,
479    rules: &AttentionRules,
480) -> ModelResult<i64> {
481    let rows = get_attention_items(conn, rules, None).await?;
482    Ok(rows
483        .iter()
484        .filter(|row| row.standing(&rules.blocking) == AttentionStanding::NeedsAttention)
485        .count() as i64)
486}
487
488/// One dismissal still in force or recently made, for the "Dismissed recently" section.
489#[derive(Debug, Clone, PartialEq)]
490pub struct AttentionDismissal {
491    pub credit_registration_id: Uuid,
492    pub user_id: Uuid,
493    pub first_name: Option<String>,
494    pub last_name: Option<String>,
495    pub email: Option<String>,
496    pub course_id: Uuid,
497    pub course_name: String,
498    pub dismissed_reasons: Vec<AttentionReason>,
499    pub dismissed_at: DateTime<Utc>,
500    pub dismissed_by_user_id: Uuid,
501    pub dismissed_by_first_name: Option<String>,
502    pub dismissed_by_last_name: Option<String>,
503    pub reason: String,
504}
505
506/// Dismissals made since `since`, newest first.
507pub async fn get_dismissals_since(
508    conn: &mut PgConnection,
509    since: DateTime<Utc>,
510    limit: i64,
511) -> ModelResult<Vec<AttentionDismissal>> {
512    let res = sqlx::query_as!(
513        AttentionDismissal,
514        r#"
515SELECT dis.credit_registration_id,
516  cr.user_id,
517  ud.first_name AS "first_name?",
518  ud.last_name AS "last_name?",
519  ud.email AS "email?",
520  cr.course_id,
521  c.name AS course_name,
522  dis.dismissed_reasons AS "dismissed_reasons: Vec<AttentionReason>",
523  dis.created_at AS dismissed_at,
524  dis.dismissed_by_user_id,
525  actor.first_name AS "dismissed_by_first_name?",
526  actor.last_name AS "dismissed_by_last_name?",
527  dis.reason
528FROM credit_registration_attention_dismissals dis
529  JOIN credit_registrations cr ON cr.id = dis.credit_registration_id
530  JOIN courses c ON c.id = cr.course_id
531  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
532  LEFT JOIN user_details actor ON actor.user_id = dis.dismissed_by_user_id
533WHERE dis.deleted_at IS NULL
534  AND dis.created_at >= $1
535ORDER BY dis.created_at DESC
536LIMIT $2
537        "#,
538        since,
539        limit,
540    )
541    .fetch_all(conn)
542    .await?;
543    Ok(res)
544}
545
546/// Takes a row off the queue for as long as it carries only `reasons`, replacing any earlier
547/// dismissal of it. Call in the transaction that records the audit action.
548pub async fn dismiss_attention(
549    conn: &mut PgConnection,
550    credit_registration_id: Uuid,
551    reasons: &[AttentionReason],
552    dismissed_by_user_id: Uuid,
553    reason: &str,
554) -> ModelResult<Uuid> {
555    sqlx::query!(
556        r#"
557UPDATE credit_registration_attention_dismissals
558SET deleted_at = now()
559WHERE credit_registration_id = $1
560  AND deleted_at IS NULL
561        "#,
562        credit_registration_id,
563    )
564    .execute(&mut *conn)
565    .await?;
566    let id = sqlx::query_scalar!(
567        r#"
568INSERT INTO credit_registration_attention_dismissals (
569    credit_registration_id,
570    dismissed_reasons,
571    dismissed_by_user_id,
572    reason
573  )
574VALUES ($1, $2, $3, $4)
575RETURNING id
576        "#,
577        credit_registration_id,
578        reasons as &[AttentionReason],
579        dismissed_by_user_id,
580        reason,
581    )
582    .fetch_one(conn)
583    .await?;
584    Ok(id)
585}