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 std::collections::HashMap;
24
25use headless_lms_base::config::ApplicationConfiguration;
26use headless_lms_models::credit_registration_account_linking_emails::{
27    self, CreditRegistrationAccountLinkingEmail,
28};
29use headless_lms_models::credit_registration_admin_actions::{self, ManualLinkingEmailSend};
30use headless_lms_models::credit_registration_events::{
31    self, CreditRegistrationEventKind, NewCreditRegistrationEvent,
32};
33use headless_lms_models::credit_registrations::AttentionRules;
34use headless_lms_models::email_deliveries::EmailSendStatusReport;
35use headless_lms_models::student_number_verification_tokens;
36use utoipa::{OpenApi, ToSchema};
37
38use crate::domain::authorization::AuthorizationToken;
39use crate::prelude::*;
40use secrecy::ExposeSecret;
41
42#[derive(OpenApi)]
43#[openapi(paths(
44    dashboard::get_credit_registration_overview,
45    dashboard::get_suotar_health,
46    dashboard::admin_pause_phase,
47    dashboard::admin_resume_phase,
48    dashboard::admin_run_phase_now,
49    ledger::list_credit_registrations_for_admin,
50    ledger::get_credit_registration_for_admin,
51    ledger::admin_transition_credit_registration,
52    ledger::admin_bulk_transition_credit_registrations,
53    ledger::admin_requeue_retryable_credit_registrations,
54    errors::get_credit_registration_thresholds,
55    errors::get_credit_registration_attention_items,
56    errors::get_credit_registration_errors_by_code,
57    errors::admin_dismiss_credit_registration_attention,
58    errors::admin_add_credit_registration_note,
59    phases::list_credit_registration_phases,
60    api_log::list_suotar_api_calls,
61    api_log::get_suotar_api_call,
62    courses::get_credit_registration_stats_by_course,
63    courses::admin_pause_course_module_credit_registration,
64    courses::admin_resume_course_module_credit_registration,
65    reconciliation::get_credit_registration_reconciliation,
66    audit::list_credit_registration_admin_actions,
67    audit::get_credit_registration_earlier_reasons,
68    history::get_credit_registration_pipeline_history,
69    enrolment_checks::get_credit_registration_enrolment_checks,
70    account_linking::get_account_linking_stats,
71    account_linking::list_account_linking_emails,
72    account_linking::admin_resend_account_linking_email,
73    account_linking::admin_resolve_student_number_for_linking,
74    account_linking::admin_manually_link_student_number,
75    account_linking::admin_request_enrolment_list_fetch,
76    account_linking::admin_dismiss_study_registry_conflict,
77    account_linking::get_credit_registration_linking_candidates,
78    account_linking::get_account_linking_courses,
79    account_linking::get_account_linking_unlinked_enrolees,
80    student_numbers::list_verified_student_numbers_for_admin,
81    student_numbers::admin_unlink_student_number,
82    materialize::admin_materialize_credit_registrations
83), components(schemas(
84    // Only a query parameter names it, and utoipa takes schemas from bodies alone.
85    headless_lms_models::credit_registration_account_linking_emails::LinkingEmailListStatus
86)))]
87pub(crate) struct MainFrontendCreditRegistrationAdminApiDoc;
88
89/// The rules every Needs attention count on these tabs is taken under.
90async fn attention_rules(
91    conn: &mut PgConnection,
92    app_conf: &ApplicationConfiguration,
93) -> Result<AttentionRules, ControllerError> {
94    Ok(
95        headless_lms_credit_registration::attention::attention_rules(
96            conn,
97            app_conf.suotar_configuration.account_linking_since,
98        )
99        .await?,
100    )
101}
102
103/// Every handler here gates on the same check; a submodule calls this instead of repeating it.
104async fn authorize_credit_registration_admin(
105    conn: &mut PgConnection,
106    user_id: Uuid,
107) -> Result<AuthorizationToken, ControllerError> {
108    authorize(
109        conn,
110        Act::AdministrateCreditRegistrations,
111        Some(user_id),
112        Res::GlobalPermissions,
113    )
114    .await
115    .map_err(Into::into)
116}
117
118/// Refuses an empty or whitespace reason. Every audited action names one.
119fn required_reason(reason: &str) -> Result<&str, ControllerError> {
120    let trimmed = reason.trim();
121    if trimmed.is_empty() {
122        return Err(controller_err!(
123            BadRequest,
124            "A reason is required.".to_string()
125        ));
126    }
127    Ok(trimmed)
128}
129
130/// Records an admin action on the row's timeline without moving its state.
131async fn insert_admin_action_event(
132    tx: &mut PgConnection,
133    id: Uuid,
134    actor_user_id: Uuid,
135    reason: &str,
136) -> Result<(), ControllerError> {
137    credit_registration_events::insert(
138        tx,
139        &NewCreditRegistrationEvent {
140            actor_user_id: Some(actor_user_id),
141            message: Some(reason.to_string()),
142            ..NewCreditRegistrationEvent::new(id, CreditRegistrationEventKind::AdminAction)
143        },
144    )
145    .await?;
146    Ok(())
147}
148
149#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
150pub struct AdminLinkingEmail {
151    pub id: Uuid,
152    pub course_id: Uuid,
153    pub student_number: String,
154    pub sisu_person_id: String,
155    /// In full.
156    pub emailed_to: String,
157    pub claimed_at: DateTime<Utc>,
158    pub send_status: EmailSendStatusReport,
159    pub token_claimed_by_user_id: Option<Uuid>,
160    pub token_used_at: Option<DateTime<Utc>>,
161    pub token_expires_at: Option<DateTime<Utc>>,
162}
163
164/// A linking email someone sent by hand, so others do not send the same one again.
165#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
166pub struct AdminManualLinkingEmailSend {
167    pub sent_at: DateTime<Utc>,
168    pub sent_by_user_id: Uuid,
169    pub sent_by_first_name: Option<String>,
170    pub sent_by_last_name: Option<String>,
171    pub sent_by_email: Option<String>,
172    /// In full.
173    pub emailed_to: Option<String>,
174}
175
176impl From<ManualLinkingEmailSend> for AdminManualLinkingEmailSend {
177    fn from(send: ManualLinkingEmailSend) -> Self {
178        Self {
179            sent_at: send.sent_at,
180            sent_by_user_id: send.sent_by_user_id,
181            sent_by_first_name: send.sent_by_first_name,
182            sent_by_last_name: send.sent_by_last_name,
183            sent_by_email: send.sent_by_email,
184            emailed_to: send.emailed_to,
185        }
186    }
187}
188
189/// The linking emails sent by hand on these courses as a guess for a registration's student, newest
190/// first, by registration id.
191async fn manual_linking_email_sends_by_registration(
192    conn: &mut PgConnection,
193    course_ids: &[Uuid],
194) -> Result<HashMap<Uuid, Vec<AdminManualLinkingEmailSend>>, ControllerError> {
195    let mut by_registration: HashMap<Uuid, Vec<AdminManualLinkingEmailSend>> = HashMap::new();
196    if course_ids.is_empty() {
197        return Ok(by_registration);
198    }
199    let sends =
200        credit_registration_admin_actions::get_manual_linking_email_sends(conn, course_ids).await?;
201    for send in sends {
202        if let Some(id) = send.credit_registration_id {
203            by_registration.entry(id).or_default().push(send.into());
204        }
205    }
206    Ok(by_registration)
207}
208
209/// Shared by the ledger detail view and the account-linking admin views: both show a person's linking
210/// mails alongside the token each one carries.
211async fn build_linking_emails(
212    conn: &mut PgConnection,
213    mails: Vec<CreditRegistrationAccountLinkingEmail>,
214) -> Result<Vec<AdminLinkingEmail>, ControllerError> {
215    let ids: Vec<Uuid> = mails.iter().map(|mail| mail.id).collect();
216    let reports =
217        credit_registration_account_linking_emails::get_send_status_reports(conn, &ids).await?;
218    let token_ids: Vec<Uuid> = mails
219        .iter()
220        .filter_map(|mail| mail.student_number_verification_token_id)
221        .collect();
222    let tokens = student_number_verification_tokens::get_by_ids(conn, &token_ids).await?;
223    Ok(mails
224        .into_iter()
225        .map(|mail| {
226            let token = mail
227                .student_number_verification_token_id
228                .and_then(|token_id| tokens.get(&token_id));
229            AdminLinkingEmail {
230                send_status: reports.get(&mail.id).cloned().unwrap_or_else(
231                    credit_registration_account_linking_emails::not_handed_over_yet,
232                ),
233                id: mail.id,
234                course_id: mail.course_id,
235                student_number: mail.student_number.expose_secret().to_owned(),
236                sisu_person_id: mail.sisu_person_id.expose_secret().to_owned(),
237                emailed_to: mail.emailed_to.expose_secret().to_owned(),
238                claimed_at: mail.sent_at,
239                token_claimed_by_user_id: token.and_then(|row| row.claimed_by_user_id),
240                token_used_at: token.and_then(|row| row.used_at),
241                token_expires_at: token.map(|row| row.expires_at),
242            }
243        })
244        .collect())
245}
246
247pub fn _add_routes(cfg: &mut ServiceConfig) {
248    dashboard::_add_routes(cfg);
249    ledger::_add_routes(cfg);
250    errors::_add_routes(cfg);
251    phases::_add_routes(cfg);
252    api_log::_add_routes(cfg);
253    courses::_add_routes(cfg);
254    reconciliation::_add_routes(cfg);
255    audit::_add_routes(cfg);
256    history::_add_routes(cfg);
257    enrolment_checks::_add_routes(cfg);
258    account_linking::_add_routes(cfg);
259    student_numbers::_add_routes(cfg);
260    materialize::_add_routes(cfg);
261}