Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
errors.rs

1//! The Needs attention tab, and the error codes the System tab shows.
2
3use headless_lms_base::config::ApplicationConfiguration;
4use headless_lms_models::credit_registration_admin_actions::{
5    self, CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
6    CreditRegistrationNote, GLOBAL_ADMIN_ROLE, NewCreditRegistrationAdminAction,
7};
8use headless_lms_models::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
9use headless_lms_models::credit_registration_events::{self, ErrorCodeWindowCounts};
10use headless_lms_models::credit_registrations::{
11    self, AttentionDismissal, AttentionReason, AttentionRegistration, AttentionStanding,
12    BlockingProblem, BlockingProblems, CreditRegistrationErrorCode, CreditRegistrationState,
13    HandActionAvailability, ResubmissionStrictness, StuckThresholds,
14};
15use headless_lms_models::library::credit_registration::classification::{
16    Retryability, retryability,
17};
18use headless_lms_models::library::credit_registration::timeline::{
19    Engagement, TimelinePhase, TimelineStep, WaitsOn,
20};
21use headless_lms_models::suotar_api_calls::SuotarEndpoint;
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23use utoipa::ToSchema;
24
25use crate::domain::credit_registration::health::stuck_thresholds;
26use crate::prelude::*;
27use headless_lms_utils::secret_string::expose_option;
28
29use super::{
30    AdminManualLinkingEmailSend, attention_rules, authorize_credit_registration_admin,
31    insert_admin_action_event, manual_linking_email_sends_by_registration, required_reason,
32};
33
34const RECENT_DISMISSAL_DAYS: i64 = 14;
35const RECENT_DISMISSAL_LIMIT: i64 = 200;
36const DEFAULT_ERROR_WINDOW_SECS: i64 = 24 * 60 * 60;
37const MAX_ERROR_WINDOW_SECS: i64 = 90 * 24 * 60 * 60;
38
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
40pub struct CreditRegistrationAttentionItem {
41    pub credit_registration_id: Uuid,
42    pub user_id: Uuid,
43    pub first_name: Option<String>,
44    pub last_name: Option<String>,
45    /// In full: this is the list support works from.
46    pub email: Option<String>,
47    pub course_id: Uuid,
48    pub course_name: String,
49    pub course_module_id: Uuid,
50    pub course_module_name: Option<String>,
51    pub uh_course_code: Option<String>,
52    pub state: CreditRegistrationState,
53    /// When the row entered its state; same-state checks do not move it.
54    pub state_changed_at: DateTime<Utc>,
55    pub phase_started_at: DateTime<Utc>,
56    pub timeline_step: TimelineStep,
57    pub phase: TimelinePhase,
58    pub waits_on: WaitsOn,
59    /// Only on the steps that wait on the student.
60    pub engagement: Option<Engagement>,
61    pub error_code: Option<CreditRegistrationErrorCode>,
62    pub attempt_count: i32,
63    pub next_attempt_at: DateTime<Utc>,
64    pub student_number: Option<String>,
65    /// Every reason that picked this row.
66    pub reasons: Vec<AttentionReason>,
67    pub standing: AttentionStanding,
68    /// Set exactly when `standing` is `explained_by_problem`.
69    pub blocking_problem: Option<BlockingProblem>,
70    /// The student's latest "I have enrolled" press.
71    pub pressed_at: Option<DateTime<Utc>>,
72    pub enrolment_route: Option<CreditRegistrationEnrolmentRoute>,
73    /// When the code's latest enrolment list fetch that could send a linking email started.
74    pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
75    pub is_enrolment_list_empty: bool,
76    /// The pipeline's own flag; a fact about the row, separate from any dismissal.
77    pub needs_admin_attention: bool,
78    /// What the bulk hand transition would allow on this row.
79    pub hand_actions: HandActionAvailability,
80    /// Linking emails sent by hand as a guess for this row's student, newest first.
81    pub manual_linking_email_sends: Vec<AdminManualLinkingEmailSend>,
82    /// Newest first.
83    pub notes: Vec<CreditRegistrationNote>,
84}
85
86impl CreditRegistrationAttentionItem {
87    /// Someone has done something about the row since it entered its phase, without taking it off
88    /// the queue. An older reaction was to a problem the row may no longer have.
89    fn is_reacted_to(&self) -> bool {
90        self.notes
91            .iter()
92            .any(|note| note.created_at >= self.phase_started_at)
93            || self
94                .manual_linking_email_sends
95                .iter()
96                .any(|send| send.sent_at >= self.phase_started_at)
97    }
98}
99
100/// How many listed rows someone has reacted to, over the problem groups, the phases and running
101/// late alike.
102#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
103pub struct CreditRegistrationAttentionReactionCounts {
104    pub reacted_count: i64,
105    pub not_reacted_count: i64,
106}
107
108#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
109pub struct CreditRegistrationAttentionReasonCount {
110    pub reason: AttentionReason,
111    pub count: i64,
112}
113
114/// One blocking problem and the rows it accounts for.
115#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
116pub struct CreditRegistrationBlockingProblemRows {
117    pub problem: BlockingProblem,
118    /// Every row the problem accounts for, oldest first.
119    pub items: Vec<CreditRegistrationAttentionItem>,
120}
121
122/// The rows of one timeline phase that need attention.
123#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
124pub struct CreditRegistrationAttentionPhaseRows {
125    pub phase: TimelinePhase,
126    /// Every row of the phase matching the filters, in the requested order.
127    pub items: Vec<CreditRegistrationAttentionItem>,
128}
129
130/// A dismissal made recently, for the "Dismissed recently" section.
131#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
132pub struct CreditRegistrationAttentionDismissal {
133    pub credit_registration_id: Uuid,
134    pub user_id: Uuid,
135    pub first_name: Option<String>,
136    pub last_name: Option<String>,
137    pub email: Option<String>,
138    pub course_id: Uuid,
139    pub course_name: String,
140    pub dismissed_reasons: Vec<AttentionReason>,
141    pub dismissed_at: DateTime<Utc>,
142    pub dismissed_by_user_id: Uuid,
143    pub dismissed_by_first_name: Option<String>,
144    pub dismissed_by_last_name: Option<String>,
145    pub reason: String,
146}
147
148#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
149pub struct CreditRegistrationAttentionItems {
150    /// The Needs attention count, whatever this request filtered to: the tab badge, the same number
151    /// `/overview` reports.
152    pub total_count: i64,
153    /// Over the whole Needs attention section, so the counts stay usable as facets.
154    pub counts_by_reason: Vec<CreditRegistrationAttentionReasonCount>,
155    /// The rows needing attention that match `reason`, one entry per timeline phase that has any,
156    /// in timeline order.
157    pub phases: Vec<CreditRegistrationAttentionPhaseRows>,
158    /// Over a timing threshold with no person-level cause, oldest first. Not in the count.
159    pub running_late: Vec<CreditRegistrationAttentionItem>,
160    /// Rows a blocking problem accounts for, under that problem. Not in the count.
161    pub explained_by_problem: Vec<CreditRegistrationBlockingProblemRows>,
162    /// Dismissals of the last 14 days, newest first, whether or not the row has come back since.
163    pub dismissed_recently: Vec<CreditRegistrationAttentionDismissal>,
164    /// Before `reacted` narrows the rows, so both stay usable as facets.
165    pub reaction_counts: CreditRegistrationAttentionReactionCounts,
166}
167
168#[derive(Debug, Deserialize, ToSchema)]
169pub struct AdminDismissAttentionPayload {
170    pub reason: String,
171}
172
173/// The body of `POST /registrations/{credit_registration_id}/notes`.
174#[derive(Debug, Deserialize, ToSchema)]
175pub struct AdminAddCreditRegistrationNotePayload {
176    pub note: String,
177}
178
179#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
180pub struct AdminDismissAttentionResult {
181    /// The reasons the dismissal covers; a different one firing brings the row back.
182    pub dismissed_reasons: Vec<AttentionReason>,
183}
184
185/// One error code over the chosen window and the one before it.
186#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
187pub struct CreditRegistrationErrorCodeWindow {
188    pub error_code: CreditRegistrationErrorCode,
189    /// What may be done about the code, which is the difference between a wait and a fix.
190    pub retryability: Retryability,
191    /// Live rows carrying the code now: what the Registrations list filtered by `error_code`
192    /// shows. The other counts are failure events in the window.
193    pub live_count: i64,
194    pub current_count: i64,
195    pub previous_count: i64,
196    pub user_count: i64,
197    pub course_count: i64,
198    pub first_seen_at: Option<DateTime<Utc>>,
199    pub last_seen_at: Option<DateTime<Utc>>,
200    pub endpoints: Vec<SuotarEndpoint>,
201}
202
203/// The verdicts an operator needs beside the errors to rule them out. `not_improved` is not a
204/// failure and is never in the error table above.
205#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
206pub struct CreditRegistrationTerminalVerdicts {
207    pub registered_count: i64,
208    pub duplicate_and_not_improved_count: i64,
209    pub failed_permanent_count: i64,
210    pub cancelled_count: i64,
211    /// The denominator of the success rate.
212    pub total_count: i64,
213}
214
215#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
216pub struct CreditRegistrationErrorsByCode {
217    pub window_secs: i64,
218    pub codes: Vec<CreditRegistrationErrorCodeWindow>,
219    pub verdicts: CreditRegistrationTerminalVerdicts,
220}
221
222#[derive(Debug, Deserialize)]
223pub struct ErrorWindowQuery {
224    window_secs: Option<i64>,
225}
226
227/**
228GET `/api/v0/main-frontend/credit-registration-admin/thresholds` - Every number the alert rules and
229the stuck detectors use.
230
231The same values `/overview` embeds in its health block. Separate so a tab explaining "stuck after
2322 hours" can say so without reading the whole overview aggregate.
233*/
234#[instrument(skip(pool))]
235#[utoipa::path(
236    get,
237    path = "/thresholds",
238    operation_id = "getCreditRegistrationThresholds",
239    tag = "credit-registration-admin",
240    responses(
241        (status = 200, description = "The thresholds every rule and detector shares", body = StuckThresholds)
242    )
243)]
244pub async fn get_credit_registration_thresholds(
245    user: AuthUser,
246    pool: web::Data<PgPool>,
247) -> ControllerResult<web::Json<StuckThresholds>> {
248    let mut conn = pool.acquire().await?;
249    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
250    token.authorized_ok(web::Json(stuck_thresholds()))
251}
252
253#[derive(Debug, Deserialize)]
254pub struct AttentionQuery {
255    reason: Option<Vec<AttentionReason>>,
256    sort: Option<String>,
257    reacted: Option<bool>,
258}
259
260/**
261GET `/api/v0/main-frontend/credit-registration-admin/attention` - The Needs attention tab: the rows
262that need a person by timeline phase, and the rows running late, explained by a blocking problem or
263recently dismissed.
264
265Superseded attempts are outside every detector: acting on a replaced attempt is never right.
266`total_count` is the one Needs attention count; `/overview`'s `needs_attention_count` is the same
267number.
268*/
269#[instrument(skip(pool, app_conf))]
270#[utoipa::path(
271    get,
272    path = "/attention",
273    operation_id = "getCreditRegistrationAttentionItems",
274    tag = "credit-registration-admin",
275    params(
276        ("reason" = Option<Vec<AttentionReason>>, Query, description = "Only rows carrying one of these reasons; repeat the parameter for several"),
277        ("sort" = Option<String>, Query, description = "time_in_phase, next_attempt or course"),
278        ("reacted" = Option<bool>, Query, description = "Only rows someone has, or has not, left a note on or sent a linking email for by hand since the row entered its phase; the counts cover every row")
279    ),
280    responses(
281        (status = 200, description = "The Needs attention sections", body = CreditRegistrationAttentionItems)
282    )
283)]
284pub async fn get_credit_registration_attention_items(
285    user: AuthUser,
286    pool: web::Data<PgPool>,
287    query: MultiQuery<AttentionQuery>,
288    app_conf: web::Data<ApplicationConfiguration>,
289) -> ControllerResult<web::Json<CreditRegistrationAttentionItems>> {
290    let mut conn = pool.acquire().await?;
291    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
292
293    let reasons: &[AttentionReason] = query.reason.as_deref().unwrap_or_default();
294    let rules = attention_rules(&mut conn, &app_conf).await?;
295    let rows = credit_registrations::get_attention_items(&mut conn, &rules, None).await?;
296    let course_ids: Vec<Uuid> = rows
297        .iter()
298        .map(|row| row.course_id)
299        .collect::<BTreeSet<_>>()
300        .into_iter()
301        .collect();
302    let mut sends = manual_linking_email_sends_by_registration(&mut conn, &course_ids).await?;
303    let registration_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
304    let mut notes: HashMap<Uuid, Vec<CreditRegistrationNote>> = HashMap::new();
305    for note in
306        credit_registration_admin_actions::get_notes_by_registrations(&mut conn, &registration_ids)
307            .await?
308    {
309        notes
310            .entry(note.credit_registration_id)
311            .or_default()
312            .push(note);
313    }
314    let dismissed_recently = credit_registrations::get_dismissals_since(
315        &mut conn,
316        Utc::now() - chrono::Duration::days(RECENT_DISMISSAL_DAYS),
317        RECENT_DISMISSAL_LIMIT,
318    )
319    .await?
320    .into_iter()
321    .map(to_dismissal)
322    .collect();
323
324    let mut needing: Vec<CreditRegistrationAttentionItem> = Vec::new();
325    let mut running_late = Vec::new();
326    let mut explained_by_problem: Vec<CreditRegistrationBlockingProblemRows> = Vec::new();
327    let matches_reacted = |item: &CreditRegistrationAttentionItem| {
328        query
329            .reacted
330            .is_none_or(|reacted| reacted == item.is_reacted_to())
331    };
332    let mut reaction_counts = CreditRegistrationAttentionReactionCounts {
333        reacted_count: 0,
334        not_reacted_count: 0,
335    };
336    for item in rows.into_iter().map(|row| {
337        let row_sends = sends.remove(&row.id).unwrap_or_default();
338        let row_notes = notes.remove(&row.id).unwrap_or_default();
339        to_attention_item(row, &rules.blocking, row_sends, row_notes)
340    }) {
341        if item.standing != AttentionStanding::Dismissed {
342            if item.is_reacted_to() {
343                reaction_counts.reacted_count += 1;
344            } else {
345                reaction_counts.not_reacted_count += 1;
346            }
347        }
348        // The tab badge and the reason facets count the whole queue, so needing rows are narrowed
349        // only after counting.
350        if item.standing != AttentionStanding::NeedsAttention && !matches_reacted(&item) {
351            continue;
352        }
353        match item.standing {
354            AttentionStanding::NeedsAttention => needing.push(item),
355            AttentionStanding::RunningLate => running_late.push(item),
356            AttentionStanding::ExplainedByProblem => {
357                let Some(problem) = item.blocking_problem.clone() else {
358                    continue;
359                };
360                match explained_by_problem
361                    .iter_mut()
362                    .find(|group| group.problem == problem)
363                {
364                    Some(group) => group.items.push(item),
365                    None => explained_by_problem.push(CreditRegistrationBlockingProblemRows {
366                        problem,
367                        items: vec![item],
368                    }),
369                }
370            }
371            AttentionStanding::Dismissed => {}
372        }
373    }
374    let mut reason_counts: BTreeMap<AttentionReason, i64> = BTreeMap::new();
375    for reason in needing.iter().flat_map(|item| item.reasons.iter()) {
376        *reason_counts.entry(*reason).or_insert(0) += 1;
377    }
378    let counts_by_reason = reason_counts
379        .into_iter()
380        .map(|(reason, count)| CreditRegistrationAttentionReasonCount { reason, count })
381        .collect();
382    let total_count = needing.len() as i64;
383
384    let mut by_phase: BTreeMap<TimelinePhase, Vec<CreditRegistrationAttentionItem>> =
385        BTreeMap::new();
386    for item in needing.into_iter().filter(|item| {
387        (reasons.is_empty() || item.reasons.iter().any(|reason| reasons.contains(reason)))
388            && matches_reacted(item)
389    }) {
390        by_phase.entry(item.phase).or_default().push(item);
391    }
392    let phases = by_phase
393        .into_iter()
394        .map(|(phase, mut items)| {
395            match query.sort.as_deref() {
396                Some("next_attempt") => items.sort_by_key(|item| item.next_attempt_at),
397                Some("course") => items.sort_by(|a, b| a.course_name.cmp(&b.course_name)),
398                _ => items.sort_by_key(|item| item.phase_started_at),
399            }
400            CreditRegistrationAttentionPhaseRows { phase, items }
401        })
402        .collect();
403
404    token.authorized_ok(web::Json(CreditRegistrationAttentionItems {
405        total_count,
406        counts_by_reason,
407        phases,
408        running_late,
409        explained_by_problem,
410        dismissed_recently,
411        reaction_counts,
412    }))
413}
414
415/**
416POST `/api/v0/main-frontend/credit-registration-admin/registrations/{credit_registration_id}/dismiss-attention`
417- Takes a row off the Needs attention queue.
418
419The dismissal covers the reasons the row carries now; it comes back only when a different one
420fires. Leaves the pipeline's own flag alone.
421*/
422#[instrument(skip(pool, payload, app_conf))]
423#[utoipa::path(
424    post,
425    path = "/registrations/{credit_registration_id}/dismiss-attention",
426    operation_id = "adminDismissCreditRegistrationAttention",
427    tag = "credit-registration-admin",
428    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
429    request_body = AdminDismissAttentionPayload,
430    responses(
431        (status = 200, description = "The reasons dismissed", body = AdminDismissAttentionResult),
432        (status = 400, description = "No reason given, or nothing picks the row"),
433    )
434)]
435pub async fn admin_dismiss_credit_registration_attention(
436    user: AuthUser,
437    pool: web::Data<PgPool>,
438    credit_registration_id: web::Path<Uuid>,
439    payload: web::Json<AdminDismissAttentionPayload>,
440    app_conf: web::Data<ApplicationConfiguration>,
441) -> ControllerResult<web::Json<AdminDismissAttentionResult>> {
442    let mut conn = pool.acquire().await?;
443    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
444
445    let reason = required_reason(&payload.reason)?;
446    let id = *credit_registration_id;
447    let rules = attention_rules(&mut conn, &app_conf).await?;
448    let reasons = credit_registrations::get_attention_items(&mut conn, &rules, Some(&[id]))
449        .await?
450        .into_iter()
451        .next()
452        .map(|row| row.reasons)
453        .unwrap_or_default();
454    if reasons.is_empty() {
455        return Err(controller_err!(
456            BadRequest,
457            "Nothing puts this registration on the Needs attention queue.".to_string()
458        ));
459    }
460
461    let mut tx = conn.begin().await?;
462    credit_registrations::dismiss_attention(&mut tx, id, &reasons, user.id, reason).await?;
463    insert_admin_action_event(&mut tx, id, user.id, reason).await?;
464    models::credit_registration_admin_actions::record(
465        &mut tx,
466        &NewCreditRegistrationAdminAction {
467            target_id: Some(id),
468            reason: Some(reason.to_string()),
469            details: Some(serde_json::json!({ "attention_reasons": reasons })),
470            affected_row_count: Some(1),
471            ..NewCreditRegistrationAdminAction::new(
472                CreditRegistrationAdminAction::DismissAttention,
473                CreditRegistrationAdminActionTarget::CreditRegistration,
474                user.id,
475                GLOBAL_ADMIN_ROLE,
476            )
477        },
478    )
479    .await?;
480    tx.commit().await?;
481
482    token.authorized_ok(web::Json(AdminDismissAttentionResult {
483        dismissed_reasons: reasons,
484    }))
485}
486
487/**
488POST `/api/v0/main-frontend/credit-registration-admin/registrations/{credit_registration_id}/notes`
489- Leaves a note on a registration for whoever handles it next.
490
491Changes nothing else: the row stays on the Needs attention queue, counted as reacted to.
492*/
493#[instrument(skip(pool, payload))]
494#[utoipa::path(
495    post,
496    path = "/registrations/{credit_registration_id}/notes",
497    operation_id = "adminAddCreditRegistrationNote",
498    tag = "credit-registration-admin",
499    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
500    request_body = AdminAddCreditRegistrationNotePayload,
501    responses(
502        (status = 200, description = "The note was added"),
503        (status = 400, description = "The note is empty"),
504    )
505)]
506pub async fn admin_add_credit_registration_note(
507    user: AuthUser,
508    pool: web::Data<PgPool>,
509    credit_registration_id: web::Path<Uuid>,
510    payload: web::Json<AdminAddCreditRegistrationNotePayload>,
511) -> ControllerResult<web::Json<()>> {
512    let mut conn = pool.acquire().await?;
513    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
514
515    let note = required_reason(&payload.note)?;
516    let id = *credit_registration_id;
517    // Refuses an unknown id with a 404 rather than an orphan audit row.
518    credit_registrations::get_by_id(&mut conn, id).await?;
519
520    let mut tx = conn.begin().await?;
521    insert_admin_action_event(&mut tx, id, user.id, note).await?;
522    models::credit_registration_admin_actions::record(
523        &mut tx,
524        &NewCreditRegistrationAdminAction {
525            target_id: Some(id),
526            reason: Some(note.to_string()),
527            affected_row_count: Some(1),
528            ..NewCreditRegistrationAdminAction::new(
529                CreditRegistrationAdminAction::AddNote,
530                CreditRegistrationAdminActionTarget::CreditRegistration,
531                user.id,
532                GLOBAL_ADMIN_ROLE,
533            )
534        },
535    )
536    .await?;
537    tx.commit().await?;
538
539    token.authorized_ok(web::Json(()))
540}
541
542/**
543GET `/api/v0/main-frontend/credit-registration-admin/errors/by-code` - Error events per code over a
544window and the window before it, with the terminal verdicts of the same window beside them.
545
546Counts events, not rows: an error that happened really happened, whether or not a later attempt
547succeeded, and hiding it would hide the configuration bug that caused it.
548*/
549#[instrument(skip(pool))]
550#[utoipa::path(
551    get,
552    path = "/errors/by-code",
553    operation_id = "getCreditRegistrationErrorsByCode",
554    tag = "credit-registration-admin",
555    params(("window_secs" = Option<i64>, Query, description = "Window length in seconds; the same length before it is the comparison")),
556    responses(
557        (status = 200, description = "Per-code counts and the window's verdicts", body = CreditRegistrationErrorsByCode)
558    )
559)]
560pub async fn get_credit_registration_errors_by_code(
561    user: AuthUser,
562    pool: web::Data<PgPool>,
563    query: web::Query<ErrorWindowQuery>,
564) -> ControllerResult<web::Json<CreditRegistrationErrorsByCode>> {
565    let mut conn = pool.acquire().await?;
566    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
567
568    let window_secs = query
569        .window_secs
570        .unwrap_or(DEFAULT_ERROR_WINDOW_SECS)
571        .clamp(60, MAX_ERROR_WINDOW_SECS);
572    let live_counts = credit_registrations::count_by_error_code(&mut conn).await?;
573    let codes =
574        credit_registration_events::get_error_code_counts_for_window(&mut conn, window_secs)
575            .await?
576            .into_iter()
577            .map(|row| {
578                let live_count = live_counts
579                    .iter()
580                    .find(|live| live.error_code == row.error_code)
581                    .map_or(0, |live| live.live_count);
582                to_error_code_window(row, live_count)
583            })
584            .collect();
585    let totals = credit_registrations::count_terminal_outcomes_since(
586        &mut conn,
587        Utc::now() - chrono::Duration::seconds(window_secs),
588    )
589    .await?;
590
591    token.authorized_ok(web::Json(CreditRegistrationErrorsByCode {
592        window_secs,
593        codes,
594        verdicts: CreditRegistrationTerminalVerdicts {
595            registered_count: totals.registered_count,
596            duplicate_and_not_improved_count: totals.success_count - totals.registered_count,
597            failed_permanent_count: totals.failed_permanent_count,
598            cancelled_count: totals.cancelled_count,
599            total_count: totals.total_count,
600        },
601    }))
602}
603
604pub(super) fn to_attention_item(
605    row: AttentionRegistration,
606    problems: &BlockingProblems,
607    manual_linking_email_sends: Vec<AdminManualLinkingEmailSend>,
608    notes: Vec<CreditRegistrationNote>,
609) -> CreditRegistrationAttentionItem {
610    let position = row.position();
611    let standing = row.standing(problems);
612    let waits_on = WaitsOn::of(
613        position.step,
614        position.engagement,
615        standing == AttentionStanding::NeedsAttention,
616        row.needs_course_setup(),
617    );
618    CreditRegistrationAttentionItem {
619        blocking_problem: (standing == AttentionStanding::ExplainedByProblem)
620            .then(|| row.blocking_problem(problems))
621            .flatten(),
622        hand_actions: row
623            .resubmission_facts()
624            .hand_actions(ResubmissionStrictness::AnyExceptSubmissionUncertain),
625        timeline_step: position.step,
626        phase: position.step.phase(),
627        engagement: position.engagement,
628        waits_on,
629        standing,
630        credit_registration_id: row.id,
631        user_id: row.user_id,
632        first_name: row.first_name,
633        last_name: row.last_name,
634        email: row.email,
635        course_id: row.course_id,
636        course_name: row.course_name,
637        course_module_id: row.course_module_id,
638        course_module_name: row.course_module_name,
639        uh_course_code: row.uh_course_code,
640        state: row.state,
641        state_changed_at: row.state_changed_at,
642        phase_started_at: row.phase_started_at,
643        error_code: row.error_code,
644        attempt_count: row.attempt_count,
645        next_attempt_at: row.next_attempt_at,
646        student_number: expose_option(&row.student_number).map(str::to_owned),
647        reasons: row.reasons,
648        pressed_at: row.pressed_at,
649        enrolment_route: row.enrolment_route,
650        last_mailing_fetch_started_at: row.last_mailing_fetch_started_at,
651        is_enrolment_list_empty: row.is_enrolment_list_empty,
652        needs_admin_attention: row.needs_admin_attention,
653        manual_linking_email_sends,
654        notes,
655    }
656}
657
658fn to_dismissal(row: AttentionDismissal) -> CreditRegistrationAttentionDismissal {
659    CreditRegistrationAttentionDismissal {
660        credit_registration_id: row.credit_registration_id,
661        user_id: row.user_id,
662        first_name: row.first_name,
663        last_name: row.last_name,
664        email: row.email,
665        course_id: row.course_id,
666        course_name: row.course_name,
667        dismissed_reasons: row.dismissed_reasons,
668        dismissed_at: row.dismissed_at,
669        dismissed_by_user_id: row.dismissed_by_user_id,
670        dismissed_by_first_name: row.dismissed_by_first_name,
671        dismissed_by_last_name: row.dismissed_by_last_name,
672        reason: row.reason,
673    }
674}
675
676fn to_error_code_window(
677    row: ErrorCodeWindowCounts,
678    live_count: i64,
679) -> CreditRegistrationErrorCodeWindow {
680    CreditRegistrationErrorCodeWindow {
681        live_count,
682        retryability: retryability(row.error_code),
683        error_code: row.error_code,
684        current_count: row.current_count,
685        previous_count: row.previous_count,
686        user_count: row.user_count,
687        course_count: row.course_count,
688        first_seen_at: row.first_seen_at,
689        last_seen_at: row.last_seen_at,
690        endpoints: row.endpoints,
691    }
692}
693
694pub fn _add_routes(cfg: &mut ServiceConfig) {
695    cfg.route(
696        "/thresholds",
697        web::get().to(get_credit_registration_thresholds),
698    )
699    .route(
700        "/attention",
701        web::get().to(get_credit_registration_attention_items),
702    )
703    .route(
704        "/errors/by-code",
705        web::get().to(get_credit_registration_errors_by_code),
706    )
707    .route(
708        "/registrations/{credit_registration_id}/dismiss-attention",
709        web::post().to(admin_dismiss_credit_registration_attention),
710    )
711    .route(
712        "/registrations/{credit_registration_id}/notes",
713        web::post().to(admin_add_credit_registration_note),
714    );
715}