Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
mod.rs

1/*!
2Handlers for HTTP requests to `/api/v0/main-frontend/credit-registration-admin`.
3
4Every mutating handler writes its `credit_registration_admin_actions` row in the transaction that has
5the effect. Admins see recipient addresses in full and the scrubbed study registry bodies; the
6registry's own error text is returned to nobody.
7*/
8
9mod account_linking;
10mod api_log;
11mod audit;
12mod courses;
13mod dashboard;
14mod enrolment_checks;
15mod errors;
16mod history;
17mod ledger;
18mod materialize;
19mod phases;
20mod reconciliation;
21mod student_numbers;
22
23use headless_lms_models::credit_registration_account_linking_emails::{
24    self, CreditRegistrationAccountLinkingEmail,
25};
26use headless_lms_models::email_deliveries::EmailSendStatusReport;
27use headless_lms_models::student_number_verification_tokens;
28use utoipa::{OpenApi, ToSchema};
29
30use crate::domain::authorization::AuthorizationToken;
31use crate::prelude::*;
32use secrecy::ExposeSecret;
33
34#[derive(OpenApi)]
35#[openapi(paths(
36    dashboard::get_credit_registration_overview,
37    dashboard::get_suotar_health,
38    dashboard::admin_pause_phase,
39    dashboard::admin_resume_phase,
40    dashboard::admin_run_phase_now,
41    ledger::list_credit_registrations_for_admin,
42    ledger::get_credit_registration_for_admin,
43    ledger::admin_transition_credit_registration,
44    ledger::admin_bulk_transition_credit_registrations,
45    ledger::admin_requeue_retryable_credit_registrations,
46    errors::get_credit_registration_thresholds,
47    errors::get_credit_registration_attention_items,
48    errors::get_credit_registration_errors_by_code,
49    phases::list_credit_registration_phases,
50    api_log::list_suotar_api_calls,
51    api_log::get_suotar_api_call,
52    courses::get_credit_registration_stats_by_course,
53    courses::admin_pause_course_module_credit_registration,
54    courses::admin_resume_course_module_credit_registration,
55    reconciliation::get_credit_registration_reconciliation,
56    audit::list_credit_registration_admin_actions,
57    history::get_credit_registration_pipeline_history,
58    enrolment_checks::get_credit_registration_enrolment_checks,
59    account_linking::get_account_linking_stats,
60    account_linking::admin_resend_account_linking_email,
61    account_linking::admin_resolve_student_number_for_linking,
62    account_linking::admin_manually_link_student_number,
63    student_numbers::list_verified_student_numbers_for_admin,
64    student_numbers::admin_unlink_student_number,
65    materialize::admin_materialize_credit_registrations
66))]
67pub(crate) struct MainFrontendCreditRegistrationAdminApiDoc;
68
69/// Resends after which retrying is not the answer and the attention queue picks the row up. Verify
70/// polls do not count: confirming routinely takes many. Shared so the Overview tile and the Errors
71/// queue count the same rows.
72const ATTENTION_TOO_MANY_ATTEMPTS: i32 = 5;
73
74/// Every handler here gates on the same check; a submodule calls this instead of repeating it.
75async fn authorize_credit_registration_admin(
76    conn: &mut PgConnection,
77    user_id: Uuid,
78) -> Result<AuthorizationToken, ControllerError> {
79    authorize(
80        conn,
81        Act::AdministrateCreditRegistrations,
82        Some(user_id),
83        Res::GlobalPermissions,
84    )
85    .await
86    .map_err(Into::into)
87}
88
89/// Refuses an empty or whitespace reason. Every audited action names one.
90fn required_reason(reason: &str) -> Result<&str, ControllerError> {
91    let trimmed = reason.trim();
92    if trimmed.is_empty() {
93        return Err(controller_err!(
94            BadRequest,
95            "A reason is required.".to_string()
96        ));
97    }
98    Ok(trimmed)
99}
100
101#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
102pub struct AdminLinkingEmail {
103    pub id: Uuid,
104    pub course_id: Uuid,
105    pub student_number: String,
106    pub sisu_person_id: String,
107    /// In full.
108    pub emailed_to: String,
109    pub claimed_at: DateTime<Utc>,
110    pub send_status: EmailSendStatusReport,
111    pub token_claimed_by_user_id: Option<Uuid>,
112    pub token_used_at: Option<DateTime<Utc>>,
113    pub token_expires_at: Option<DateTime<Utc>>,
114}
115
116/// Shared by the ledger detail view and the account-linking admin views: both show a person's linking
117/// mails alongside the token each one carries.
118async fn build_linking_emails(
119    conn: &mut PgConnection,
120    mails: Vec<CreditRegistrationAccountLinkingEmail>,
121) -> Result<Vec<AdminLinkingEmail>, ControllerError> {
122    let ids: Vec<Uuid> = mails.iter().map(|mail| mail.id).collect();
123    let reports =
124        credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
125    let token_ids: Vec<Uuid> = mails
126        .iter()
127        .filter_map(|mail| mail.student_number_verification_token_id)
128        .collect();
129    let tokens = student_number_verification_tokens::get_by_ids(conn, &token_ids).await?;
130    Ok(mails
131        .into_iter()
132        .map(|mail| {
133            let token = mail
134                .student_number_verification_token_id
135                .and_then(|token_id| tokens.get(&token_id));
136            AdminLinkingEmail {
137                send_status: reports.get(&mail.id).cloned().unwrap_or_else(
138                    credit_registration_account_linking_emails::not_handed_over_yet,
139                ),
140                id: mail.id,
141                course_id: mail.course_id,
142                student_number: mail.student_number.expose_secret().to_owned(),
143                sisu_person_id: mail.sisu_person_id.expose_secret().to_owned(),
144                emailed_to: mail.emailed_to.expose_secret().to_owned(),
145                claimed_at: mail.sent_at,
146                token_claimed_by_user_id: token.and_then(|row| row.claimed_by_user_id),
147                token_used_at: token.and_then(|row| row.used_at),
148                token_expires_at: token.map(|row| row.expires_at),
149            }
150        })
151        .collect())
152}
153
154pub fn _add_routes(cfg: &mut ServiceConfig) {
155    dashboard::_add_routes(cfg);
156    ledger::_add_routes(cfg);
157    errors::_add_routes(cfg);
158    phases::_add_routes(cfg);
159    api_log::_add_routes(cfg);
160    courses::_add_routes(cfg);
161    reconciliation::_add_routes(cfg);
162    audit::_add_routes(cfg);
163    history::_add_routes(cfg);
164    enrolment_checks::_add_routes(cfg);
165    account_linking::_add_routes(cfg);
166    student_numbers::_add_routes(cfg);
167    materialize::_add_routes(cfg);
168}