Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
ledger.rs

1//! Viewing and hand-transitioning rows of the credit registration ledger.
2
3use headless_lms_base::config::ApplicationConfiguration;
4use headless_lms_models::credit_registration_account_linking_emails;
5use headless_lms_models::credit_registration_admin_actions::{
6    CreditRegistrationAdminAction, CreditRegistrationAdminActionFilters,
7    CreditRegistrationAdminActionRecord, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
8    NewCreditRegistrationAdminAction,
9};
10use headless_lms_models::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
11use headless_lms_models::credit_registration_events::{
12    CreditRegistrationEventKind, NotImprovedAttainment, SuotarAnswer,
13};
14use headless_lms_models::credit_registration_roster_schedules::{self, ScheduleSelection};
15use headless_lms_models::credit_registrations::{
16    self, AdminCreditRegistration, AdminCreditRegistrationFilters, AdminCreditRegistrationSort,
17    AttentionReason, AttentionStanding, BlockingProblem, CreditRegistrationErrorCode,
18    CreditRegistrationState, HandActionAvailability, ResubmissionFacts, ResubmissionRefusal,
19    ResubmissionStrictness, Transition,
20};
21use headless_lms_models::email_deliveries::EmailSendStatusReport;
22use headless_lms_models::library::credit_registration::CreditRegistrationPendingReason;
23use headless_lms_models::library::credit_registration::backoff::{
24    NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD, PARTIAL_REGISTRATION_ADMIN_AFTER,
25    UNCERTAIN_ADMIN_AFTER, VERIFY_MAX_AGE,
26};
27use headless_lms_models::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
28use headless_lms_models::library::credit_registration::student_notifications::{
29    self, CreditRegistrationNotificationKind, RegistrationNotificationEmail,
30};
31use headless_lms_models::library::credit_registration::timeline::{
32    Engagement, TimelinePhase, TimelineStep, WaitsOn,
33};
34use headless_lms_models::suotar_api_calls;
35use headless_lms_models::verified_student_numbers::{self, StudentNumberVerificationMethod};
36use std::collections::{HashMap, HashSet};
37use utoipa::ToSchema;
38
39use crate::prelude::*;
40use headless_lms_utils::secret_string::expose_option;
41use secrecy::{ExposeSecret, SecretString};
42
43use super::{
44    AdminLinkingEmail, AdminManualLinkingEmailSend, attention_rules,
45    authorize_credit_registration_admin, build_linking_emails, insert_admin_action_event,
46    manual_linking_email_sends_by_registration, required_reason,
47};
48
49#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
50pub struct AdminCreditRegistrationRow {
51    pub id: Uuid,
52    pub created_at: DateTime<Utc>,
53    pub user_id: Uuid,
54    pub first_name: Option<String>,
55    pub last_name: Option<String>,
56    /// In full: masking it would leave support unable to answer the question they were asked.
57    pub email: Option<String>,
58    pub course_id: Uuid,
59    pub course_name: String,
60    pub course_module_id: Uuid,
61    pub course_module_name: Option<String>,
62    pub course_instance_id: Uuid,
63    pub course_module_completion_id: Uuid,
64    pub completion_date: DateTime<Utc>,
65    pub state: CreditRegistrationState,
66    /// What a `pending` row is waiting on, which the ledger does not store; `null` for every other
67    /// state.
68    pub pending_reason: Option<CreditRegistrationPendingReason>,
69    pub timeline_step: TimelineStep,
70    pub phase: TimelinePhase,
71    pub waits_on: WaitsOn,
72    /// Only on the steps that wait on the student.
73    pub engagement: Option<Engagement>,
74    /// When the row entered its timeline phase: what "In this phase since" shows.
75    pub phase_started_at: DateTime<Utc>,
76    /// When the row entered its state. Same-state checks move `state_entered_at` but not this.
77    pub state_changed_at: DateTime<Utc>,
78    /// Moved by every write that keeps the state too, so it is when the row was last touched, not
79    /// how long it has been where it is.
80    pub state_entered_at: DateTime<Utc>,
81    /// Every reason the Needs attention queue picks the row for; empty when it is not picked.
82    pub attention_reasons: Vec<AttentionReason>,
83    /// Which Needs attention section the row is in; `None` when it is not picked.
84    pub attention_standing: Option<AttentionStanding>,
85    /// The student's latest "I have enrolled" press for the completion.
86    pub pressed_at: Option<DateTime<Utc>>,
87    pub enrolment_route: Option<CreditRegistrationEnrolmentRoute>,
88    pub last_visited_at: Option<DateTime<Utc>>,
89    pub error_code: Option<CreditRegistrationErrorCode>,
90    pub needs_admin_attention: bool,
91    pub next_attempt_at: DateTime<Utc>,
92    pub last_attempt_at: Option<DateTime<Utc>>,
93    pub submitted_at: Option<DateTime<Utc>>,
94    pub registered_at: Option<DateTime<Utc>>,
95    pub terminal_at: Option<DateTime<Utc>>,
96    /// When verify first saw only the assessment item attainment.
97    pub partially_registered_at: Option<DateTime<Utc>>,
98    /// Suotar's `retryAfter` for a pending submission: resending earlier may duplicate it.
99    pub resubmit_not_before: Option<DateTime<Utc>>,
100    /// How many times Suotar has lost the submission and it was sent again.
101    pub not_registered_reimport_count: i32,
102    pub is_waiting_for_enrolment: bool,
103    pub no_usable_enrolment_since: Option<DateTime<Utc>>,
104    pub enrolment_checked_at: Option<DateTime<Utc>>,
105    /// The next scheduled enrolment check.
106    pub enrolment_check_due_at: Option<DateTime<Utc>>,
107    pub enrolment_checks_stopped_at: Option<DateTime<Utc>>,
108    /// Frozen on the row before it was sent, so it is what we actually submitted.
109    pub student_number: Option<String>,
110    pub sisu_person_id: Option<String>,
111    pub uh_course_code: Option<String>,
112    pub selected_enrolment_id: Option<String>,
113    pub grade_scale_id: Option<String>,
114    pub grade_id: Option<String>,
115    pub credits: Option<f32>,
116    pub submitted_attainment_id: Option<String>,
117    pub sisu_attainment_id: Option<String>,
118    pub submit_retry_count: i32,
119    pub verify_attempt_count: i32,
120    pub attempt_number: i32,
121    pub superseded: bool,
122    pub superseded_by_id: Option<Uuid>,
123    /// What the single-row hand transition would allow: what the row's action controls render from.
124    pub hand_actions: HandActionAvailability,
125    /// The account's link now, which is not always the number frozen on the row.
126    pub verified_student_number: Option<String>,
127    pub verified_student_number_at: Option<DateTime<Utc>>,
128    /// `admin_manual` means support established the link rather than the student proving it.
129    pub verified_student_number_via: Option<StudentNumberVerificationMethod>,
130}
131
132#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
133pub struct AdminCreditRegistrationEvent {
134    pub id: Uuid,
135    /// The attempt the event belongs to.
136    pub credit_registration_id: Uuid,
137    pub created_at: DateTime<Utc>,
138    pub kind: CreditRegistrationEventKind,
139    pub from_state: Option<CreditRegistrationState>,
140    pub to_state: Option<CreditRegistrationState>,
141    pub error_code: Option<CreditRegistrationErrorCode>,
142    /// Our own wording, written by the pipeline or by whoever acted.
143    pub message: Option<String>,
144    pub actor_user_id: Option<Uuid>,
145    pub suotar_api_call_id: Option<Uuid>,
146    /// The `{request, response}` pair, scrubbed at write time: names, student numbers and email
147    /// addresses read `[redacted]` while their keys survive. The values we sent are on the row.
148    pub details: Option<serde_json::Value>,
149    /// The requestItemId the row went out under in the call behind this event.
150    pub request_item_id: Option<String>,
151    pub suotar_endpoint: Option<suotar_api_calls::SuotarEndpoint>,
152    pub suotar_requested_at: Option<DateTime<Utc>>,
153    pub suotar_answered_at: Option<DateTime<Utc>>,
154    pub suotar_answer: Option<SuotarAnswer>,
155    /// Suotar's own per-item code, e.g. `enrolmentNotFound`, which `error_code` classifies and
156    /// sometimes drops. `None` when no item answer came back.
157    pub suotar_code: Option<String>,
158}
159
160#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
161pub struct AdminSuotarApiCall {
162    pub id: Uuid,
163    pub endpoint: suotar_api_calls::SuotarEndpoint,
164    pub started_at: DateTime<Utc>,
165    pub duration_ms: Option<i32>,
166    pub http_status: Option<i32>,
167    pub succeeded: bool,
168    pub request_level_error_code: Option<String>,
169    pub worker_name: String,
170}
171
172/// One of the two student terminal-state mails, in full: `send_status.failure_code` is what drives
173/// the decision to look at the relay.
174#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
175pub struct AdminNotificationEmail {
176    /// The attempt the mail was sent for.
177    pub credit_registration_id: Uuid,
178    pub kind: CreditRegistrationNotificationKind,
179    /// The delivery this registration is pinned to, so support can find the message in the queue and
180    /// tell "still the first mail" from "a second one went out".
181    pub email_delivery_id: Uuid,
182    pub send_status: EmailSendStatusReport,
183}
184
185/// The pipeline's own limits for asking an admin to look, from `library::credit_registration::backoff`.
186#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
187pub struct AdminAttentionThresholds {
188    pub partial_registration_secs: i64,
189    pub uncertain_secs: i64,
190    pub verify_window_secs: i64,
191    pub not_registered_reimports: i32,
192}
193
194impl AdminAttentionThresholds {
195    pub const CURRENT: Self = Self {
196        partial_registration_secs: PARTIAL_REGISTRATION_ADMIN_AFTER.num_seconds(),
197        uncertain_secs: UNCERTAIN_ADMIN_AFTER.num_seconds(),
198        verify_window_secs: VERIFY_MAX_AGE.num_seconds(),
199        not_registered_reimports: NOT_REGISTERED_REIMPORT_ADMIN_THRESHOLD,
200    };
201}
202
203/// What the student did around the completion, with full timestamps, for the timeline.
204#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
205pub struct AdminCreditRegistrationJourney {
206    /// When they first picked any language version of the course.
207    pub course_started_at: Option<DateTime<Utc>>,
208    /// The first visit to the registration page; a check request recorded before any visit makes
209    /// this the time of that request instead.
210    pub first_visited_at: Option<DateTime<Utc>>,
211    /// Only the latest visit is kept after the first.
212    pub last_visited_at: Option<DateTime<Utc>>,
213    pub last_check_requested_at: Option<DateTime<Utc>>,
214    pub check_request_source: Option<EnrolmentCheckSource>,
215    /// How they enrolled.
216    pub enrolment_route: Option<CreditRegistrationEnrolmentRoute>,
217    /// The latest "I have enrolled" press; overwritten by a later one, cleared if taken back.
218    pub pressed_at: Option<DateTime<Utc>>,
219    /// Sisu's own time for the enrolment we found, where Sisu gave one.
220    pub sisu_enrolled_at: Option<DateTime<Utc>>,
221}
222
223/// For a student who pressed "I have enrolled" and has no linked student number: their course
224/// code's enrolment list schedule, and what went out on the code since the press.
225#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
226pub struct AdminLinkingSchedule {
227    pub course_code: String,
228    pub last_fetched_at: Option<DateTime<Utc>>,
229    /// The latest fetch that could send linking emails; the stuck rule counts from here.
230    pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
231    pub next_fetch_at: DateTime<Utc>,
232    pub is_fetch_failing: bool,
233    pub is_enrolment_list_empty: bool,
234    /// On any course sharing the code. Not attributable to this student until a link is used.
235    pub linking_emails_since_press: i64,
236    pub last_linking_email_at: Option<DateTime<Utc>>,
237}
238
239/// The row's standing on the Needs attention queue.
240#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
241pub struct AdminRegistrationAttention {
242    pub reasons: Vec<AttentionReason>,
243    pub standing: AttentionStanding,
244    pub blocking_problem: Option<BlockingProblem>,
245    /// The dismissal in force, if any; a row whose reasons all fall under it is `dismissed`.
246    pub dismissed_reasons: Option<Vec<AttentionReason>>,
247    pub dismissed_at: Option<DateTime<Utc>>,
248    pub dismissed_by_user_id: Option<Uuid>,
249    pub dismissed_by_first_name: Option<String>,
250    pub dismissed_by_last_name: Option<String>,
251    pub dismissal_reason: Option<String>,
252}
253
254#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
255pub struct AdminCreditRegistrationDetails {
256    pub attention_thresholds: AdminAttentionThresholds,
257    pub registration: AdminCreditRegistrationRow,
258    /// `None` when nothing picks the row.
259    pub attention: Option<AdminRegistrationAttention>,
260    /// Read from the newest attempt, whichever attempt was asked for.
261    pub journey: AdminCreditRegistrationJourney,
262    /// Of the newest attempt, as `journey` is, and only while it waits for a student number after an
263    /// "I have enrolled" press.
264    pub linking_schedule: Option<AdminLinkingSchedule>,
265    /// Every attempt for the same completion, newest first, this one included.
266    pub attempts: Vec<AdminCreditRegistrationRow>,
267    /// Every attempt's events, oldest first, so one timeline covers the whole completion.
268    pub events: Vec<AdminCreditRegistrationEvent>,
269    /// The calls the timeline refers to, newest first.
270    pub suotar_api_calls: Vec<AdminSuotarApiCall>,
271    /// Admin and teacher actions targeting this row.
272    pub actions: Vec<CreditRegistrationAdminActionRecord>,
273    /// Every mail addressed to this person, on any course.
274    pub linking_emails: Vec<AdminLinkingEmail>,
275    /// Linking emails sent by hand as a guess for this row's student, newest first.
276    pub manual_linking_email_sends: Vec<AdminManualLinkingEmailSend>,
277    /// The student mails queued for every attempt ("Please register" is `action_needed`), with the
278    /// same send status the student and the teacher are shown.
279    pub notification_emails: Vec<AdminNotificationEmail>,
280    /// The grade the registry already held, for a row it declined as no improvement. The row's own
281    /// grade is what we sent.
282    pub not_improved_attainment: Option<NotImprovedAttainment>,
283}
284
285/// The states an admin may move a row to; everything else is the pipeline's to decide.
286#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
287#[serde(rename_all = "snake_case")]
288pub enum AdminCreditRegistrationStateMove {
289    /// Resubmit: the escape hatch out of `submission_uncertain`, and how a `misregistered` row is
290    /// tried again.
291    ReadyToSubmit,
292    Cancelled,
293}
294
295impl AdminCreditRegistrationStateMove {
296    fn to_state(self) -> CreditRegistrationState {
297        match self {
298            Self::ReadyToSubmit => CreditRegistrationState::ReadyToSubmit,
299            Self::Cancelled => CreditRegistrationState::Cancelled,
300        }
301    }
302}
303
304/// What one hand action does to a row: either a state move, or something that leaves the state
305/// alone. Kept apart because only the first is a transition, and only the first is refusable.
306#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
307#[serde(rename_all = "snake_case", tag = "kind")]
308pub enum AdminCreditRegistrationAction {
309    StateMove {
310        to_state: AdminCreditRegistrationStateMove,
311    },
312    /// Stops the row asking for a human.
313    ClearNeedsAdminAttention,
314    /// Makes the row due, so the phase owning its state claims it on the next pass instead of
315    /// waiting out a backoff of up to a day.
316    CheckNow,
317}
318
319impl AdminCreditRegistrationAction {
320    /// Why this action is refused on the row, or `None` if it may go ahead.
321    fn refusal(
322        self,
323        facts: &ResubmissionFacts,
324        strictness: ResubmissionStrictness,
325    ) -> Option<ResubmissionRefusal> {
326        match self {
327            Self::StateMove { to_state } => {
328                facts.admin_transition_refusal(to_state.to_state(), strictness)
329            }
330            Self::CheckNow => facts.check_now_refusal(),
331            // Even clearing a flag on a replaced attempt is an admin acting on the wrong row.
332            Self::ClearNeedsAdminAttention if facts.is_superseded => {
333                Some(ResubmissionRefusal::Superseded)
334            }
335            Self::ClearNeedsAdminAttention => None,
336        }
337    }
338}
339
340#[derive(Debug, Deserialize, ToSchema)]
341pub struct AdminTransitionCreditRegistrationPayload {
342    pub action: AdminCreditRegistrationAction,
343    pub reason: String,
344}
345
346#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
347#[serde(rename_all = "snake_case")]
348pub enum AdminTransitionOutcome {
349    Applied,
350    /// The row was left where it was; `refusal` says why.
351    Refused,
352    NoChange,
353}
354
355#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
356pub struct AdminTransitionCreditRegistrationResult {
357    pub outcome: AdminTransitionOutcome,
358    /// Set exactly when the outcome is `refused`.
359    pub refusal: Option<ResubmissionRefusal>,
360    pub state: CreditRegistrationState,
361    pub needs_admin_attention: bool,
362}
363
364/// A fat-finger bound on one selection; a bigger one is taken in several passes.
365const MAX_ROWS_PER_BULK_TRANSITION: i64 = 500;
366const MAX_ROWS_PER_REQUEUE: i64 = 5_000;
367/// A detail view's related-rows lookups (other attempts, calls, actions) never paginate; this just
368/// bounds them against a pathological completion.
369const MAX_RELATED_ROWS: i64 = u8::MAX as i64;
370
371#[derive(Debug, Deserialize, ToSchema)]
372pub struct AdminBulkTransitionPayload {
373    pub action: AdminCreditRegistrationAction,
374    pub credit_registration_ids: Vec<Uuid>,
375    pub reason: String,
376}
377
378#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
379pub struct AdminBulkTransitionSkipCount {
380    pub refusal: ResubmissionRefusal,
381    pub count: i64,
382}
383
384#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
385pub struct AdminBulkTransitionResult {
386    pub applied_count: i64,
387    pub skipped: Vec<AdminBulkTransitionSkipCount>,
388    /// Distinct selected ids naming no live row.
389    pub not_found_count: i64,
390    pub max_rows_per_call: i64,
391}
392
393#[derive(Debug, Deserialize, ToSchema)]
394pub struct AdminRequeueRetryablePayload {
395    pub course_id: Option<Uuid>,
396    pub course_module_id: Option<Uuid>,
397    pub reason: String,
398}
399
400#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
401pub struct AdminRequeueRetryableResult {
402    pub requeued_count: i64,
403    pub max_rows_per_call: i64,
404}
405
406#[derive(Debug, Deserialize)]
407pub struct ListCreditRegistrationsQuery {
408    page: Option<u32>,
409    limit: Option<u32>,
410    state: Option<Vec<CreditRegistrationState>>,
411    error_code: Option<Vec<CreditRegistrationErrorCode>>,
412    course_id: Option<Uuid>,
413    course_module_id: Option<Uuid>,
414    user_id: Option<Uuid>,
415    student_number: Option<SecretString>,
416    step: Option<Vec<TimelineStep>>,
417    engagement: Option<Vec<Engagement>>,
418    include_not_started: Option<bool>,
419    needs_attention: Option<bool>,
420    attention_reason: Option<Vec<AttentionReason>>,
421    submitted_after: Option<DateTime<Utc>>,
422    submitted_before: Option<DateTime<Utc>>,
423    search: Option<SecretString>,
424    include_superseded: Option<bool>,
425    sort: Option<String>,
426}
427
428/**
429GET `/api/v0/main-frontend/credit-registration-admin/registrations` - A page of the ledger, filtered
430and sorted.
431*/
432#[instrument(skip(pool, app_conf))]
433#[utoipa::path(
434    get,
435    path = "/registrations",
436    operation_id = "listCreditRegistrationsForAdmin",
437    tag = "credit-registration-admin",
438    params(
439        ("page" = Option<u32>, Query, description = "Page number, from 1"),
440        ("limit" = Option<u32>, Query, description = "Rows per page"),
441        ("state" = Option<Vec<CreditRegistrationState>>, Query, description = "Ledger states; repeat the parameter for several"),
442        ("error_code" = Option<Vec<CreditRegistrationErrorCode>>, Query, description = "Error codes; repeat the parameter for several"),
443        ("course_id" = Option<Uuid>, Query, description = "Course filter"),
444        ("course_module_id" = Option<Uuid>, Query, description = "Course module filter"),
445        ("user_id" = Option<Uuid>, Query, description = "Student filter"),
446        ("student_number" = Option<String>, Query, description = "Exact student number, frozen on the row or linked to the account"),
447        ("step" = Option<Vec<TimelineStep>>, Query, description = "Timeline steps; repeat the parameter for several. A row waiting for a student number matches only if it completed since account linking began, as the overview counts it"),
448        ("engagement" = Option<Vec<Engagement>>, Query, description = "Only rows at a step that waits on the student whose student did this; repeat for several"),
449        ("include_not_started" = Option<bool>, Query, description = "Include rows at a step that waits on the student whose student has not started; left out by default"),
450        ("needs_attention" = Option<bool>, Query, description = "Only rows counted in Needs attention"),
451        ("attention_reason" = Option<Vec<AttentionReason>>, Query, description = "Only rows the Needs attention queue picks for one of these reasons, whatever their section; repeat for several"),
452        ("submitted_after" = Option<DateTime<Utc>>, Query, description = "Submitted at or after"),
453        ("submitted_before" = Option<DateTime<Utc>>, Query, description = "Submitted at or before"),
454        ("search" = Option<String>, Query, description = "Name, email, student number, attainment id, stored error text, or a uuid"),
455        ("include_superseded" = Option<bool>, Query, description = "Include replaced attempts"),
456        ("sort" = Option<String>, Query, description = "last_activity (the default; rows counted in Needs attention first), created, time_in_state or attempts")
457    ),
458    responses(
459        (status = 200, description = "A page of the ledger", body = Page<AdminCreditRegistrationRow>)
460    )
461)]
462pub async fn list_credit_registrations_for_admin(
463    user: AuthUser,
464    pool: web::Data<PgPool>,
465    query: MultiQuery<ListCreditRegistrationsQuery>,
466    app_conf: web::Data<ApplicationConfiguration>,
467) -> ControllerResult<web::Json<Page<AdminCreditRegistrationRow>>> {
468    let mut conn = pool.acquire().await?;
469    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
470
471    let pagination = parse_pagination(query.page, query.limit, 50)?;
472    let search = non_empty(expose_option(&query.search));
473    let student_number = non_empty(expose_option(&query.student_number));
474    let sort = match query.sort.as_deref() {
475        Some("created") => AdminCreditRegistrationSort::Created,
476        Some("time_in_state") => AdminCreditRegistrationSort::TimeInState,
477        Some("attempts") => AdminCreditRegistrationSort::Attempts,
478        _ => AdminCreditRegistrationSort::LastActivity,
479    };
480    let only_needs_attention = query.needs_attention.unwrap_or(false);
481    let attention_reasons: &[AttentionReason] =
482        query.attention_reason.as_deref().unwrap_or_default();
483    let is_selected_by_attention = only_needs_attention || !attention_reasons.is_empty();
484    let whole_queue =
485        if is_selected_by_attention || sort == AdminCreditRegistrationSort::LastActivity {
486            Some(AttentionLookup::load(&mut conn, &app_conf, None).await?)
487        } else {
488            None
489        };
490    let needs_attention_ids = whole_queue
491        .as_ref()
492        .filter(|_| sort == AdminCreditRegistrationSort::LastActivity)
493        .map(|queue| queue.ids(|row| row.standing == AttentionStanding::NeedsAttention));
494    let selected_ids = whole_queue
495        .as_ref()
496        .filter(|_| is_selected_by_attention)
497        .map(|queue| {
498            queue.ids(|row| {
499                (!only_needs_attention || row.standing == AttentionStanding::NeedsAttention)
500                    && (attention_reasons.is_empty()
501                        || row
502                            .reasons
503                            .iter()
504                            .any(|reason| attention_reasons.contains(reason)))
505            })
506        });
507    let filters = AdminCreditRegistrationFilters {
508        states: query.state.as_deref(),
509        error_codes: query.error_code.as_deref(),
510        course_id: query.course_id,
511        course_module_id: query.course_module_id,
512        user_id: query.user_id,
513        student_number,
514        steps: query.step.as_deref().unwrap_or_default(),
515        engagements: query.engagement.as_deref().unwrap_or_default(),
516        hide_not_started: !query.include_not_started.unwrap_or(false),
517        account_linking_since: app_conf.suotar_configuration.account_linking_since,
518        first_ids: needs_attention_ids.as_deref(),
519        credit_registration_ids: selected_ids.as_deref(),
520        submitted_after: query.submitted_after,
521        submitted_before: query.submitted_before,
522        search,
523        search_id: search.and_then(|search| Uuid::parse_str(search).ok()),
524        include_superseded: query.include_superseded.unwrap_or(false),
525        ..AdminCreditRegistrationFilters::default()
526    };
527
528    let rows = credit_registrations::get_admin_facing(
529        &mut conn,
530        &filters,
531        sort,
532        pagination.limit(),
533        pagination.offset(),
534    )
535    .await?;
536    let total_count = rows.first().map_or(0, |row| row.total_count);
537    let attention = match whole_queue {
538        Some(queue) => queue,
539        None => {
540            let page_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
541            AttentionLookup::load(&mut conn, &app_conf, Some(&page_ids)).await?
542        }
543    };
544    let data = rows
545        .into_iter()
546        .map(|row| to_admin_row(row, &attention))
547        .collect();
548
549    token.authorized_ok(web::Json(Page::new(pagination, data, total_count)))
550}
551
552/**
553GET `/api/v0/main-frontend/credit-registration-admin/registrations/{credit_registration_id}` - One
554row with its timeline, the calls that timeline refers to, the other attempts for the same completion,
555the actions taken on it and its linking mails.
556*/
557#[instrument(skip(pool, app_conf))]
558#[utoipa::path(
559    get,
560    path = "/registrations/{credit_registration_id}",
561    operation_id = "getCreditRegistrationForAdmin",
562    tag = "credit-registration-admin",
563    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
564    responses(
565        (status = 200, description = "The row and everything that happened to it", body = AdminCreditRegistrationDetails),
566        (status = 404, description = "No such registration")
567    )
568)]
569pub async fn get_credit_registration_for_admin(
570    user: AuthUser,
571    pool: web::Data<PgPool>,
572    credit_registration_id: web::Path<Uuid>,
573    app_conf: web::Data<ApplicationConfiguration>,
574) -> ControllerResult<web::Json<AdminCreditRegistrationDetails>> {
575    let mut conn = pool.acquire().await?;
576    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
577
578    let id = *credit_registration_id;
579    let registration = one_admin_row(&mut conn, id)
580        .await?
581        .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
582    let attempts = credit_registrations::get_admin_facing(
583        &mut conn,
584        &AdminCreditRegistrationFilters {
585            user_id: Some(registration.user_id),
586            course_id: Some(registration.course_id),
587            course_module_completion_id: Some(registration.course_module_completion_id),
588            include_superseded: true,
589            ..AdminCreditRegistrationFilters::default()
590        },
591        AdminCreditRegistrationSort::Created,
592        MAX_RELATED_ROWS,
593        0,
594    )
595    .await?;
596    let attempt_ids: Vec<Uuid> = attempts.iter().map(|attempt| attempt.id).collect();
597    let attention_lookup = AttentionLookup::load(&mut conn, &app_conf, Some(&attempt_ids)).await?;
598    let newest_attempt_id = attempts.first().map_or(id, |attempt| attempt.id);
599    let attempts: Vec<AdminCreditRegistrationRow> = attempts
600        .into_iter()
601        .map(|row| to_admin_row(row, &attention_lookup))
602        .collect();
603
604    let attempt_events =
605        models::credit_registration_events::get_by_registration_ids(&mut conn, &attempt_ids)
606            .await?;
607    let events: Vec<AdminCreditRegistrationEvent> = attempt_events
608        .into_iter()
609        .map(|event| AdminCreditRegistrationEvent {
610            id: event.id,
611            credit_registration_id: event.credit_registration_id,
612            created_at: event.created_at,
613            kind: event.kind,
614            from_state: event.from_state,
615            to_state: event.to_state,
616            error_code: event.error_code,
617            message: event.message,
618            actor_user_id: event.actor_user_id,
619            suotar_api_call_id: event.suotar_api_call_id,
620            suotar_code: event
621                .details
622                .as_ref()
623                .and_then(|details| details.pointer("/response/code"))
624                .and_then(|code| code.as_str())
625                .map(str::to_string),
626            details: event.details,
627            request_item_id: event.request_item_id,
628            suotar_endpoint: event.suotar_endpoint,
629            suotar_requested_at: event.suotar_requested_at,
630            suotar_answered_at: event.suotar_answered_at,
631            suotar_answer: event.suotar_answer,
632        })
633        .collect();
634    let suotar_api_calls =
635        suotar_api_calls::get_by_credit_registration_id(&mut conn, id, MAX_RELATED_ROWS)
636            .await?
637            .into_iter()
638            .map(to_admin_api_call)
639            .collect();
640    let actions = models::credit_registration_admin_actions::get_page(
641        &mut conn,
642        &CreditRegistrationAdminActionFilters {
643            target_kind: Some(CreditRegistrationAdminActionTarget::CreditRegistration),
644            target_id: Some(id),
645            ..Default::default()
646        },
647        MAX_RELATED_ROWS,
648        0,
649    )
650    .await?
651    .into_iter()
652    .map(|row| row.action)
653    .collect();
654
655    let sisu_person_id = match &registration.sisu_person_id {
656        Some(person_id) => Some(person_id.clone()),
657        None => verified_student_numbers::get_latest_including_deleted_by_user_id(
658            &mut conn,
659            registration.user_id,
660        )
661        .await?
662        .and_then(|link| link.sisu_person_id),
663    };
664    let linking_emails = match sisu_person_id {
665        Some(person_id) => {
666            let mails = credit_registration_account_linking_emails::get_by_sisu_person_id(
667                &mut conn,
668                person_id.expose_secret(),
669            )
670            .await?;
671            build_linking_emails(&mut conn, mails).await?
672        }
673        None => Vec::new(),
674    };
675
676    let manual_linking_email_sends =
677        manual_linking_email_sends_by_registration(&mut conn, &[registration.course_id])
678            .await?
679            .remove(&id)
680            .unwrap_or_default();
681
682    let notification_emails = student_notifications::get_for_registrations(&mut conn, &attempt_ids)
683        .await?
684        .into_iter()
685        .map(
686            |mail: RegistrationNotificationEmail| AdminNotificationEmail {
687                credit_registration_id: mail.credit_registration_id,
688                kind: mail.kind,
689                email_delivery_id: mail.email_delivery_id,
690                send_status: mail.send_status,
691            },
692        )
693        .collect();
694
695    let not_improved_attainment =
696        models::credit_registration_events::get_not_improved_attainment(&mut conn, id).await?;
697
698    let journey = credit_registrations::get_registration_journey(&mut conn, newest_attempt_id)
699        .await?
700        .map(|journey| AdminCreditRegistrationJourney {
701            course_started_at: journey.course_started_at,
702            first_visited_at: journey.first_visited_at,
703            last_visited_at: journey.last_visited_at,
704            last_check_requested_at: journey.last_check_requested_at,
705            check_request_source: journey.check_request_source,
706            enrolment_route: journey.enrolment_route,
707            pressed_at: journey.pressed_at,
708            sisu_enrolled_at: journey
709                .selected_enrolment_date_time
710                .as_deref()
711                .and_then(|text| DateTime::parse_from_rfc3339(text).ok())
712                .map(|at| at.with_timezone(&Utc))
713                .or(journey.checked_enrolled_at),
714        })
715        .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
716    let linking_schedule = linking_schedule_for(
717        &mut conn,
718        newest_attempt_id,
719        app_conf.suotar_configuration.account_linking_since,
720    )
721    .await?;
722    let attention = attention_lookup
723        .get(id)
724        .map(|row| AdminRegistrationAttention {
725            reasons: row.reasons.clone(),
726            standing: row.standing,
727            blocking_problem: row.blocking_problem.clone(),
728            dismissed_reasons: row.dismissed_reasons.clone(),
729            dismissed_at: row.dismissed_at,
730            dismissed_by_user_id: row.dismissed_by_user_id,
731            dismissed_by_first_name: row.dismissed_by_first_name.clone(),
732            dismissed_by_last_name: row.dismissed_by_last_name.clone(),
733            dismissal_reason: row.dismissal_reason.clone(),
734        });
735
736    token.authorized_ok(web::Json(AdminCreditRegistrationDetails {
737        attention_thresholds: AdminAttentionThresholds::CURRENT,
738        registration: to_admin_row(registration, &attention_lookup),
739        attention,
740        journey,
741        linking_schedule,
742        attempts,
743        events,
744        suotar_api_calls,
745        actions,
746        linking_emails,
747        manual_linking_email_sends,
748        notification_emails,
749        not_improved_attainment,
750    }))
751}
752
753/**
754POST `/api/v0/main-frontend/credit-registration-admin/registrations/{credit_registration_id}/transition`
755- Moves one row by hand.
756
757The escape hatch out of `submission_uncertain`, which the pipeline never leaves on its own because
758re-importing could put a second attainment on a real transcript. Even here, a row is not resubmitted
759while Suotar may still hold its earlier submission as pending (`submission_uncertain_too_recent`,
760`submission_pending`). The row's `hand_actions` says in advance what this refuses.
761*/
762#[instrument(skip(pool, payload))]
763#[utoipa::path(
764    post,
765    path = "/registrations/{credit_registration_id}/transition",
766    operation_id = "adminTransitionCreditRegistration",
767    tag = "credit-registration-admin",
768    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
769    request_body = AdminTransitionCreditRegistrationPayload,
770    responses(
771        (status = 200, description = "What the transition did", body = AdminTransitionCreditRegistrationResult),
772        (status = 422, description = "No reason given"),
773        (status = 404, description = "No such registration")
774    )
775)]
776pub async fn admin_transition_credit_registration(
777    user: AuthUser,
778    pool: web::Data<PgPool>,
779    credit_registration_id: web::Path<Uuid>,
780    payload: web::Json<AdminTransitionCreditRegistrationPayload>,
781) -> ControllerResult<web::Json<AdminTransitionCreditRegistrationResult>> {
782    let mut conn = pool.acquire().await?;
783    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
784
785    let reason = required_reason(&payload.reason)?;
786    let id = *credit_registration_id;
787    let row = credit_registrations::get_by_id(&mut conn, id).await?;
788    if row.superseded_by_id.is_some() {
789        return Err(controller_err!(
790            BadRequest,
791            "This attempt has been replaced by a later one. Act on the later one.".to_string()
792        ));
793    }
794
795    // `Any`: a human is already looking at this one row, so unlike the bulk transition below it is
796    // not refused for being `submission_uncertain`.
797    if let Some(refusal) = payload
798        .action
799        .refusal(&row.resubmission_facts(), ResubmissionStrictness::Any)
800    {
801        return token.authorized_ok(web::Json(AdminTransitionCreditRegistrationResult {
802            outcome: AdminTransitionOutcome::Refused,
803            refusal: Some(refusal),
804            state: row.state,
805            needs_admin_attention: row.needs_admin_attention,
806        }));
807    }
808
809    let mut tx = conn.begin().await?;
810    let applied = apply_transition(&mut tx, &row, payload.action, user.id, reason).await?;
811    if applied.needs_due_now {
812        credit_registrations::make_due_now_batch(
813            &mut tx,
814            &[id],
815            EnrolmentCheckSource::AdminRequest,
816        )
817        .await?;
818    }
819    models::credit_registration_admin_actions::record(
820        &mut tx,
821        &NewCreditRegistrationAdminAction {
822            target_id: Some(id),
823            reason: Some(reason.to_string()),
824            before_state: Some(row.state),
825            after_state: Some(applied.state),
826            details: Some(serde_json::json!({ "outcome": applied.outcome })),
827            affected_row_count: Some(1),
828            ..NewCreditRegistrationAdminAction::new(
829                CreditRegistrationAdminAction::TransitionItem,
830                CreditRegistrationAdminActionTarget::CreditRegistration,
831                user.id,
832                GLOBAL_ADMIN_ROLE,
833            )
834        },
835    )
836    .await?;
837    tx.commit().await?;
838
839    token.authorized_ok(web::Json(AdminTransitionCreditRegistrationResult {
840        outcome: applied.outcome,
841        refusal: None,
842        state: applied.state,
843        needs_admin_attention: applied.needs_admin_attention,
844    }))
845}
846
847/**
848POST `/api/v0/main-frontend/credit-registration-admin/registrations/bulk-transition` - Moves a
849selection of rows by hand, one transaction for the lot.
850
851Resubmitting or cancelling refuses every row in `submission_uncertain`, whatever the selection
852said. Taking one of those back to `ready_to_submit` is a decision about one student's transcript,
853made after somebody has looked the attainment up; a checkbox in a list is not that, and a mis-click
854here would put a second attainment on every one of them. Those rows are reported back untouched, to
855be dealt with one at a time, as is a row whose earlier submission Suotar still holds open
856(`submission_pending`). Each attention item's `hand_actions` says in advance what this skips.
857*/
858#[instrument(skip(pool, payload))]
859#[utoipa::path(
860    post,
861    path = "/registrations/bulk-transition",
862    operation_id = "adminBulkTransitionCreditRegistrations",
863    tag = "credit-registration-admin",
864    request_body = AdminBulkTransitionPayload,
865    responses(
866        (status = 200, description = "What each selected row did", body = AdminBulkTransitionResult),
867        (status = 422, description = "No reason given, or more ids than one call may take")
868    )
869)]
870pub async fn admin_bulk_transition_credit_registrations(
871    user: AuthUser,
872    pool: web::Data<PgPool>,
873    payload: web::Json<AdminBulkTransitionPayload>,
874) -> ControllerResult<web::Json<AdminBulkTransitionResult>> {
875    let mut conn = pool.acquire().await?;
876    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
877
878    let reason = required_reason(&payload.reason)?;
879    if payload.credit_registration_ids.len() as i64 > MAX_ROWS_PER_BULK_TRANSITION {
880        return Err(controller_err!(
881            BadRequest,
882            format!("At most {MAX_ROWS_PER_BULK_TRANSITION} registrations per call.")
883        ));
884    }
885
886    // A selection built by clicking can name the same row twice, and reporting the duplicate as a
887    // registration that does not exist would send an admin looking for a deleted row.
888    let ids: Vec<Uuid> = payload
889        .credit_registration_ids
890        .iter()
891        .copied()
892        .collect::<HashSet<_>>()
893        .into_iter()
894        .collect();
895
896    let mut tx = conn.begin().await?;
897    // Locked, and read inside the transaction: each row's refusal is judged here and acted on below,
898    // so a row the pipeline moves in between would make `apply_transition` refuse it and take every
899    // row already applied down with it.
900    let rows = credit_registrations::get_by_ids_for_update(&mut tx, &ids).await?;
901
902    let mut applied_count = 0;
903    let mut due_now_ids = Vec::new();
904    let mut skipped: HashMap<ResubmissionRefusal, i64> = HashMap::new();
905    for row in &rows {
906        match payload.action.refusal(
907            &row.resubmission_facts(),
908            ResubmissionStrictness::AnyExceptSubmissionUncertain,
909        ) {
910            Some(refusal) => *skipped.entry(refusal).or_insert(0) += 1,
911            None => {
912                let applied =
913                    apply_transition(&mut tx, row, payload.action, user.id, reason).await?;
914                if applied.needs_due_now {
915                    due_now_ids.push(row.id);
916                }
917                applied_count += 1;
918            }
919        }
920    }
921    // Batched rather than one `UPDATE` per row inside the loop above: the row transition needs its
922    // own audit event per row, but making it due now does not.
923    credit_registrations::make_due_now_batch(
924        &mut tx,
925        &due_now_ids,
926        EnrolmentCheckSource::AdminRequest,
927    )
928    .await?;
929    let mut skipped: Vec<AdminBulkTransitionSkipCount> = skipped
930        .into_iter()
931        .map(|(refusal, count)| AdminBulkTransitionSkipCount { refusal, count })
932        .collect();
933    skipped.sort_by_key(|skip| std::cmp::Reverse(skip.count));
934
935    models::credit_registration_admin_actions::record(
936        &mut tx,
937        &NewCreditRegistrationAdminAction {
938            reason: Some(reason.to_string()),
939            details: Some(serde_json::json!({
940                "action": payload.action,
941                "credit_registration_ids": payload.credit_registration_ids,
942                "skipped": skipped,
943            })),
944            affected_row_count: Some(applied_count),
945            ..NewCreditRegistrationAdminAction::new(
946                CreditRegistrationAdminAction::TransitionItem,
947                CreditRegistrationAdminActionTarget::CreditRegistration,
948                user.id,
949                GLOBAL_ADMIN_ROLE,
950            )
951        },
952    )
953    .await?;
954    tx.commit().await?;
955
956    token.authorized_ok(web::Json(AdminBulkTransitionResult {
957        applied_count: i64::from(applied_count),
958        skipped,
959        not_found_count: ids.len() as i64 - rows.len() as i64,
960        max_rows_per_call: MAX_ROWS_PER_BULK_TRANSITION,
961    }))
962}
963
964/**
965POST `/api/v0/main-frontend/credit-registration-admin/registrations/requeue-retryable` - Makes every
966`failed_retryable` row waiting out a backoff due now.
967
968The button pressed once the study registry says an outage is over. Touches nothing but
969`next_attempt_at`: the rows are already where the pipeline wants them, they are merely waiting.
970*/
971#[instrument(skip(pool, payload))]
972#[utoipa::path(
973    post,
974    path = "/registrations/requeue-retryable",
975    operation_id = "adminRequeueRetryableCreditRegistrations",
976    tag = "credit-registration-admin",
977    request_body = AdminRequeueRetryablePayload,
978    responses(
979        (status = 200, description = "How many were made due", body = AdminRequeueRetryableResult),
980        (status = 422, description = "No reason given")
981    )
982)]
983pub async fn admin_requeue_retryable_credit_registrations(
984    user: AuthUser,
985    pool: web::Data<PgPool>,
986    payload: web::Json<AdminRequeueRetryablePayload>,
987) -> ControllerResult<web::Json<AdminRequeueRetryableResult>> {
988    let mut conn = pool.acquire().await?;
989    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
990
991    let reason = required_reason(&payload.reason)?;
992
993    let mut tx = conn.begin().await?;
994    let requeued_count = credit_registrations::requeue_retryable_now(
995        &mut tx,
996        payload.course_id,
997        payload.course_module_id,
998        MAX_ROWS_PER_REQUEUE,
999    )
1000    .await?;
1001    models::credit_registration_admin_actions::record(
1002        &mut tx,
1003        &NewCreditRegistrationAdminAction {
1004            target_id: payload.course_id,
1005            reason: Some(reason.to_string()),
1006            details: Some(serde_json::json!({
1007                "course_id": payload.course_id,
1008                "course_module_id": payload.course_module_id,
1009            })),
1010            affected_row_count: Some(i32::try_from(requeued_count).unwrap_or(i32::MAX)),
1011            ..NewCreditRegistrationAdminAction::new(
1012                CreditRegistrationAdminAction::RequeueBatch,
1013                match payload.course_id {
1014                    Some(_) => CreditRegistrationAdminActionTarget::Course,
1015                    None => CreditRegistrationAdminActionTarget::CreditRegistration,
1016                },
1017                user.id,
1018                GLOBAL_ADMIN_ROLE,
1019            )
1020        },
1021    )
1022    .await?;
1023    tx.commit().await?;
1024
1025    token.authorized_ok(web::Json(AdminRequeueRetryableResult {
1026        requeued_count,
1027        max_rows_per_call: MAX_ROWS_PER_REQUEUE,
1028    }))
1029}
1030
1031/// What one hand action did to its row.
1032struct AppliedHandAction {
1033    outcome: AdminTransitionOutcome,
1034    /// Where the row ended up.
1035    state: CreditRegistrationState,
1036    needs_admin_attention: bool,
1037    /// The caller must still make the row due now.
1038    needs_due_now: bool,
1039}
1040
1041/// Applies one hand action in the caller's transaction.
1042///
1043/// The caller has already asked `admin_transition_refusal` whether this row may take the move,
1044/// because what a refusal is reported as differs per caller. Making the row due is left to the
1045/// caller too, rather than done here, so the bulk caller can batch it over every row it applies
1046/// instead of one `UPDATE` per row.
1047async fn apply_transition(
1048    tx: &mut PgConnection,
1049    row: &credit_registrations::CreditRegistration,
1050    action: AdminCreditRegistrationAction,
1051    actor_user_id: Uuid,
1052    reason: &str,
1053) -> Result<AppliedHandAction, ControllerError> {
1054    let id = row.id;
1055    Ok(match action {
1056        AdminCreditRegistrationAction::ClearNeedsAdminAttention => {
1057            if !row.needs_admin_attention {
1058                AppliedHandAction {
1059                    outcome: AdminTransitionOutcome::NoChange,
1060                    state: row.state,
1061                    needs_admin_attention: false,
1062                    needs_due_now: false,
1063                }
1064            } else {
1065                credit_registrations::set_needs_admin_attention(
1066                    tx,
1067                    id,
1068                    credit_registrations::AdminAttention::Clear,
1069                )
1070                .await?;
1071                insert_admin_action_event(tx, id, actor_user_id, reason).await?;
1072                AppliedHandAction {
1073                    outcome: AdminTransitionOutcome::Applied,
1074                    state: row.state,
1075                    needs_admin_attention: false,
1076                    needs_due_now: false,
1077                }
1078            }
1079        }
1080        AdminCreditRegistrationAction::CheckNow => {
1081            insert_admin_action_event(tx, id, actor_user_id, reason).await?;
1082            AppliedHandAction {
1083                outcome: AdminTransitionOutcome::Applied,
1084                state: row.state,
1085                needs_admin_attention: row.needs_admin_attention,
1086                needs_due_now: true,
1087            }
1088        }
1089        AdminCreditRegistrationAction::StateMove { to_state } => {
1090            let to_state = to_state.to_state();
1091            let after = credit_registrations::transition(
1092                tx,
1093                id,
1094                &Transition {
1095                    needs_admin_attention: Some(credit_registrations::AdminAttention::Clear),
1096                    event_kind: CreditRegistrationEventKind::AdminAction,
1097                    event_message: Some(reason.to_string()),
1098                    actor_user_id: Some(actor_user_id),
1099                    // Refuses to overwrite a row the pipeline (or another admin) has moved on since
1100                    // `row` was read. The bulk caller reads its rows locked, so only the single-row
1101                    // path can actually trip this.
1102                    expected_from_state: Some(row.state),
1103                    ..Transition::by_hand(to_state)
1104                },
1105            )
1106            .await?;
1107            // Nothing else brings the row forward, so without a due-now the resubmit would sit out
1108            // the backoff whatever failed last set.
1109            AppliedHandAction {
1110                outcome: AdminTransitionOutcome::Applied,
1111                state: after.state,
1112                needs_admin_attention: after.needs_admin_attention,
1113                needs_due_now: !after.state.is_terminal(),
1114            }
1115        }
1116    })
1117}
1118
1119async fn one_admin_row(
1120    conn: &mut PgConnection,
1121    id: Uuid,
1122) -> Result<Option<AdminCreditRegistration>, ControllerError> {
1123    let rows = credit_registrations::get_admin_facing(
1124        conn,
1125        &AdminCreditRegistrationFilters {
1126            id: Some(id),
1127            include_superseded: true,
1128            ..AdminCreditRegistrationFilters::default()
1129        },
1130        AdminCreditRegistrationSort::default(),
1131        MAX_RELATED_ROWS,
1132        0,
1133    )
1134    .await?;
1135    Ok(rows.into_iter().next())
1136}
1137
1138/// The Needs attention queue as one request saw it, for marking rows of the ledger.
1139struct AttentionLookup {
1140    /// Queue order, which `ids` preserves.
1141    rows: Vec<AttentionLookupRow>,
1142    index_by_id: HashMap<Uuid, usize>,
1143}
1144
1145struct AttentionLookupRow {
1146    id: Uuid,
1147    reasons: Vec<AttentionReason>,
1148    standing: AttentionStanding,
1149    blocking_problem: Option<BlockingProblem>,
1150    dismissed_reasons: Option<Vec<AttentionReason>>,
1151    dismissed_at: Option<DateTime<Utc>>,
1152    dismissed_by_user_id: Option<Uuid>,
1153    dismissed_by_first_name: Option<String>,
1154    dismissed_by_last_name: Option<String>,
1155    dismissal_reason: Option<String>,
1156}
1157
1158impl AttentionLookup {
1159    /// `only_ids` narrows the lookup to those rows; `None` loads the whole queue.
1160    async fn load(
1161        conn: &mut PgConnection,
1162        app_conf: &ApplicationConfiguration,
1163        only_ids: Option<&[Uuid]>,
1164    ) -> Result<Self, ControllerError> {
1165        let rules = attention_rules(conn, app_conf).await?;
1166        let rows = credit_registrations::get_attention_items(conn, &rules, only_ids)
1167            .await?
1168            .into_iter()
1169            .map(|row| {
1170                let standing = row.standing(&rules.blocking);
1171                AttentionLookupRow {
1172                    id: row.id,
1173                    standing,
1174                    blocking_problem: (standing == AttentionStanding::ExplainedByProblem)
1175                        .then(|| row.blocking_problem(&rules.blocking))
1176                        .flatten(),
1177                    reasons: row.reasons,
1178                    dismissed_reasons: row.dismissed_reasons,
1179                    dismissed_at: row.dismissed_at,
1180                    dismissed_by_user_id: row.dismissed_by_user_id,
1181                    dismissed_by_first_name: row.dismissed_by_first_name,
1182                    dismissed_by_last_name: row.dismissed_by_last_name,
1183                    dismissal_reason: row.dismissal_reason,
1184                }
1185            })
1186            .collect::<Vec<_>>();
1187        let index_by_id = rows
1188            .iter()
1189            .enumerate()
1190            .map(|(index, row)| (row.id, index))
1191            .collect();
1192        Ok(Self { rows, index_by_id })
1193    }
1194
1195    fn ids(&self, keep: impl Fn(&AttentionLookupRow) -> bool) -> Vec<Uuid> {
1196        self.rows
1197            .iter()
1198            .filter(|row| keep(row))
1199            .map(|row| row.id)
1200            .collect()
1201    }
1202
1203    fn get(&self, id: Uuid) -> Option<&AttentionLookupRow> {
1204        self.index_by_id.get(&id).map(|&index| &self.rows[index])
1205    }
1206}
1207
1208/// The code's schedule for a presser still waiting for a student number; `None` for anyone else.
1209async fn linking_schedule_for(
1210    conn: &mut PgConnection,
1211    id: Uuid,
1212    account_linking_since: Option<DateTime<Utc>>,
1213) -> Result<Option<AdminLinkingSchedule>, ControllerError> {
1214    let Some(presser) = credit_registrations::get_waiting_pressers(conn, None, Some(id))
1215        .await?
1216        .into_iter()
1217        .next()
1218    else {
1219        return Ok(None);
1220    };
1221    let Some(course_code) = presser.uh_course_code else {
1222        return Ok(None);
1223    };
1224    let Some(schedule) = credit_registration_roster_schedules::get_schedules(
1225        conn,
1226        None,
1227        ScheduleSelection::Code(&course_code),
1228        account_linking_since,
1229    )
1230    .await?
1231    .into_iter()
1232    .next() else {
1233        return Ok(None);
1234    };
1235    Ok(Some(AdminLinkingSchedule {
1236        next_fetch_at: schedule.next_fetch_at(Utc::now()),
1237        last_fetched_at: schedule.last_fetched_at,
1238        last_mailing_fetch_started_at: schedule.last_mailing_fetch_started_at,
1239        is_fetch_failing: schedule.consecutive_failures > 0,
1240        is_enrolment_list_empty: schedule.last_listed_person_count == Some(0),
1241        linking_emails_since_press: presser.linking_emails_on_code_since_press,
1242        last_linking_email_at: presser.last_linking_email_on_code_at,
1243        course_code,
1244    }))
1245}
1246
1247fn to_admin_row(
1248    row: AdminCreditRegistration,
1249    attention: &AttentionLookup,
1250) -> AdminCreditRegistrationRow {
1251    let position = row.position();
1252    let picked = attention.get(row.id);
1253    let standing = picked.map(|picked| picked.standing);
1254    AdminCreditRegistrationRow {
1255        timeline_step: position.step,
1256        phase: position.step.phase(),
1257        waits_on: WaitsOn::of(
1258            position.step,
1259            position.engagement,
1260            standing == Some(AttentionStanding::NeedsAttention),
1261            credit_registrations::needs_course_setup(row.error_code),
1262        ),
1263        engagement: position.engagement,
1264        phase_started_at: row.phase_started_at(),
1265        state_changed_at: row.state_changed_at,
1266        attention_reasons: picked
1267            .map(|picked| picked.reasons.clone())
1268            .unwrap_or_default(),
1269        attention_standing: standing,
1270        pressed_at: row.pressed_at,
1271        enrolment_route: row.enrolment_route,
1272        last_visited_at: row.last_visited_at,
1273        superseded: row.superseded_by_id.is_some(),
1274        is_waiting_for_enrolment: row.is_waiting_for_enrolment(),
1275        pending_reason: row.pending_reason(),
1276        hand_actions: row
1277            .resubmission_facts()
1278            .hand_actions(ResubmissionStrictness::Any),
1279        id: row.id,
1280        created_at: row.created_at,
1281        user_id: row.user_id,
1282        first_name: row.first_name,
1283        last_name: row.last_name,
1284        email: row.email,
1285        course_id: row.course_id,
1286        course_name: row.course_name,
1287        course_module_id: row.course_module_id,
1288        course_module_name: row.course_module_name,
1289        course_instance_id: row.course_instance_id,
1290        course_module_completion_id: row.course_module_completion_id,
1291        completion_date: row.completion_date,
1292        state: row.state,
1293        state_entered_at: row.state_entered_at,
1294        error_code: row.error_code,
1295        needs_admin_attention: row.needs_admin_attention,
1296        next_attempt_at: row.next_attempt_at,
1297        last_attempt_at: row.last_attempt_at,
1298        submitted_at: row.submitted_at,
1299        registered_at: row.registered_at,
1300        terminal_at: row.terminal_at,
1301        partially_registered_at: row.partially_registered_at,
1302        resubmit_not_before: row.resubmit_not_before,
1303        not_registered_reimport_count: row.not_registered_reimport_count,
1304        no_usable_enrolment_since: row.no_usable_enrolment_since,
1305        enrolment_checked_at: row.enrolment_checked_at,
1306        enrolment_check_due_at: row.enrolment_check_due_at,
1307        enrolment_checks_stopped_at: row.enrolment_checks_stopped_at,
1308        student_number: expose_option(&row.student_number).map(str::to_owned),
1309        sisu_person_id: expose_option(&row.sisu_person_id).map(str::to_owned),
1310        uh_course_code: row.uh_course_code,
1311        selected_enrolment_id: row.selected_enrolment_id,
1312        grade_scale_id: row.grade_scale_id,
1313        grade_id: row.grade_id,
1314        credits: row.credits,
1315        submitted_attainment_id: row.submitted_attainment_id,
1316        sisu_attainment_id: row.sisu_attainment_id,
1317        submit_retry_count: row.submit_retry_count,
1318        verify_attempt_count: row.verify_attempt_count,
1319        attempt_number: row.attempt_number,
1320        superseded_by_id: row.superseded_by_id,
1321        verified_student_number: expose_option(&row.verified_student_number).map(str::to_owned),
1322        verified_student_number_at: row.verified_student_number_at,
1323        verified_student_number_via: row.verified_student_number_via,
1324    }
1325}
1326
1327fn to_admin_api_call(call: models::suotar_api_calls::SuotarApiCall) -> AdminSuotarApiCall {
1328    AdminSuotarApiCall {
1329        id: call.id,
1330        endpoint: call.endpoint,
1331        started_at: call.started_at,
1332        duration_ms: call.duration_ms,
1333        http_status: call.http_status,
1334        succeeded: call.succeeded,
1335        request_level_error_code: call.request_level_error_code,
1336        worker_name: call.worker_name,
1337    }
1338}
1339
1340pub fn _add_routes(cfg: &mut ServiceConfig) {
1341    cfg.route(
1342        "/registrations",
1343        web::get().to(list_credit_registrations_for_admin),
1344    )
1345    .route(
1346        "/registrations/{credit_registration_id}",
1347        web::get().to(get_credit_registration_for_admin),
1348    )
1349    .route(
1350        "/registrations/bulk-transition",
1351        web::post().to(admin_bulk_transition_credit_registrations),
1352    )
1353    .route(
1354        "/registrations/requeue-retryable",
1355        web::post().to(admin_requeue_retryable_credit_registrations),
1356    )
1357    .route(
1358        "/registrations/{credit_registration_id}/transition",
1359        web::post().to(admin_transition_credit_registration),
1360    );
1361}