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 errors;
15mod history;
16mod ledger;
17mod materialize;
18mod phases;
19mod reconciliation;
20mod student_numbers;
21
22use headless_lms_models::credit_registration_account_linking_emails::{
23    self, CreditRegistrationAccountLinkingEmail,
24};
25use headless_lms_models::email_deliveries::EmailSendStatusReport;
26use headless_lms_models::student_number_verification_tokens;
27use utoipa::{OpenApi, ToSchema};
28
29use crate::domain::authorization::AuthorizationToken;
30use crate::prelude::*;
31
32#[derive(OpenApi)]
33#[openapi(paths(
34    dashboard::get_credit_registration_overview,
35    dashboard::get_suotar_health,
36    dashboard::admin_pause_phase,
37    dashboard::admin_resume_phase,
38    dashboard::admin_run_phase_now,
39    ledger::list_credit_registrations_for_admin,
40    ledger::get_credit_registration_for_admin,
41    ledger::admin_transition_credit_registration,
42    ledger::admin_bulk_transition_credit_registrations,
43    ledger::admin_requeue_retryable_credit_registrations,
44    errors::get_credit_registration_thresholds,
45    errors::get_credit_registration_attention_items,
46    errors::get_credit_registration_errors_by_code,
47    phases::list_credit_registration_phases,
48    api_log::list_suotar_api_calls,
49    api_log::get_suotar_api_call,
50    courses::get_credit_registration_stats_by_course,
51    courses::admin_pause_course_module_credit_registration,
52    courses::admin_resume_course_module_credit_registration,
53    reconciliation::get_credit_registration_reconciliation,
54    audit::list_credit_registration_admin_actions,
55    history::get_credit_registration_pipeline_history,
56    account_linking::get_account_linking_stats,
57    account_linking::admin_resend_account_linking_email,
58    account_linking::admin_resolve_student_number_for_linking,
59    account_linking::admin_manually_link_student_number,
60    student_numbers::list_verified_student_numbers_for_admin,
61    student_numbers::admin_unlink_student_number,
62    materialize::admin_materialize_credit_registrations
63))]
64pub(crate) struct MainFrontendCreditRegistrationAdminApiDoc;
65
66/// Every handler here gates on the same check; a submodule calls this instead of repeating it.
67async fn authorize_credit_registration_admin(
68    conn: &mut PgConnection,
69    user_id: Uuid,
70) -> Result<AuthorizationToken, ControllerError> {
71    authorize(
72        conn,
73        Act::Administrate,
74        Some(user_id),
75        Res::GlobalPermissions,
76    )
77    .await
78    .map_err(Into::into)
79}
80
81/// Refuses an empty or whitespace reason. Every audited action names one.
82fn required_reason(reason: &str) -> Result<&str, ControllerError> {
83    let trimmed = reason.trim();
84    if trimmed.is_empty() {
85        return Err(controller_err!(
86            BadRequest,
87            "A reason is required.".to_string()
88        ));
89    }
90    Ok(trimmed)
91}
92
93/// `serde_urlencoded` reads a single occurrence of a key as a scalar, not a one-element sequence, so
94/// a `Vec` field otherwise refuses a query string that repeats the parameter zero or one times.
95fn one_or_many<'de, D, T>(deserializer: D) -> Result<Option<Vec<T>>, D::Error>
96where
97    D: serde::Deserializer<'de>,
98    T: Deserialize<'de>,
99{
100    #[derive(Deserialize)]
101    #[serde(untagged)]
102    enum OneOrMany<T> {
103        One(T),
104        Many(Vec<T>),
105    }
106    Ok(
107        Option::<OneOrMany<T>>::deserialize(deserializer)?.map(|repr| match repr {
108            OneOrMany::One(value) => vec![value],
109            OneOrMany::Many(values) => values,
110        }),
111    )
112}
113
114#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
115pub struct AdminLinkingEmail {
116    pub id: Uuid,
117    pub course_id: Uuid,
118    pub student_number: String,
119    pub sisu_person_id: String,
120    /// In full.
121    pub emailed_to: String,
122    pub claimed_at: DateTime<Utc>,
123    pub send_status: EmailSendStatusReport,
124    pub token_claimed_by_user_id: Option<Uuid>,
125    pub token_used_at: Option<DateTime<Utc>>,
126    pub token_expires_at: Option<DateTime<Utc>>,
127}
128
129/// Shared by the ledger detail view and the account-linking admin views: both show a person's linking
130/// mails alongside the token each one carries.
131async fn build_linking_emails(
132    conn: &mut PgConnection,
133    mails: Vec<CreditRegistrationAccountLinkingEmail>,
134) -> Result<Vec<AdminLinkingEmail>, ControllerError> {
135    let ids: Vec<Uuid> = mails.iter().map(|mail| mail.id).collect();
136    let reports =
137        credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
138    let token_ids: Vec<Uuid> = mails
139        .iter()
140        .filter_map(|mail| mail.student_number_verification_token_id)
141        .collect();
142    let tokens = student_number_verification_tokens::get_by_ids(conn, &token_ids).await?;
143    Ok(mails
144        .into_iter()
145        .map(|mail| {
146            let token = mail
147                .student_number_verification_token_id
148                .and_then(|token_id| tokens.get(&token_id));
149            AdminLinkingEmail {
150                send_status: reports.get(&mail.id).cloned().unwrap_or_else(
151                    credit_registration_account_linking_emails::not_handed_over_yet,
152                ),
153                id: mail.id,
154                course_id: mail.course_id,
155                student_number: mail.student_number,
156                sisu_person_id: mail.sisu_person_id,
157                emailed_to: mail.emailed_to,
158                claimed_at: mail.sent_at,
159                token_claimed_by_user_id: token.and_then(|row| row.claimed_by_user_id),
160                token_used_at: token.and_then(|row| row.used_at),
161                token_expires_at: token.map(|row| row.expires_at),
162            }
163        })
164        .collect())
165}
166
167pub fn _add_routes(cfg: &mut ServiceConfig) {
168    dashboard::_add_routes(cfg);
169    ledger::_add_routes(cfg);
170    errors::_add_routes(cfg);
171    phases::_add_routes(cfg);
172    api_log::_add_routes(cfg);
173    courses::_add_routes(cfg);
174    reconciliation::_add_routes(cfg);
175    audit::_add_routes(cfg);
176    history::_add_routes(cfg);
177    account_linking::_add_routes(cfg);
178    student_numbers::_add_routes(cfg);
179    materialize::_add_routes(cfg);
180}