Skip to main content

headless_lms_server/controllers/main_frontend/credit_registration_admin/
student_numbers.rs

1//! Listing and unlinking verified student numbers, for spot-checking and support.
2
3use headless_lms_models::credit_registration_admin_actions::{
4    CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
5    NewCreditRegistrationAdminAction,
6};
7use headless_lms_models::credit_registration_events::CreditRegistrationEventKind;
8use headless_lms_models::library::credit_registration::student_number_change::unlink_verified_student_number;
9use headless_lms_models::verified_student_numbers::{
10    self, AdminVerifiedStudentNumber, StudentNumberVerificationMethod,
11};
12use utoipa::ToSchema;
13
14use crate::prelude::*;
15use headless_lms_utils::secret_string::expose_option;
16use secrecy::{ExposeSecret, SecretString};
17
18use super::{authorize_credit_registration_admin, required_reason};
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
21pub struct AdminVerifiedStudentNumberRow {
22    pub id: Uuid,
23    pub user_id: Uuid,
24    /// In full.
25    pub user_email: Option<String>,
26    pub first_name: Option<String>,
27    pub last_name: Option<String>,
28    pub student_number: String,
29    /// `None` for a link the study registry reported, which names no person.
30    pub sisu_person_id: Option<String>,
31    pub verified_at: DateTime<Utc>,
32    pub verified_via: StudentNumberVerificationMethod,
33    /// The registry-held address the proof rests on, in full. `None` for an admin-established link.
34    pub verified_via_email: Option<String>,
35    pub linked_by_user_id: Option<Uuid>,
36    pub link_reason: Option<String>,
37    pub verified_from_course_id: Option<Uuid>,
38    pub live_registration_count: i64,
39}
40
41#[derive(Debug, Deserialize, ToSchema)]
42pub struct AdminUnlinkStudentNumberPayload {
43    pub reason: String,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
47pub struct AdminUnlinkStudentNumberResult {
48    /// Registrations that went back to waiting for a number.
49    pub affected_registration_count: i64,
50}
51
52#[derive(Debug, Deserialize)]
53pub struct ListVerifiedStudentNumbersQuery {
54    page: Option<u32>,
55    limit: Option<u32>,
56    verified_via: Option<StudentNumberVerificationMethod>,
57    search: Option<SecretString>,
58}
59
60/**
61GET `/api/v0/main-frontend/credit-registration-admin/student-numbers` - A page of the live links, for
62spot-checking and support.
63*/
64#[instrument(skip(pool))]
65#[utoipa::path(
66    get,
67    path = "/student-numbers",
68    operation_id = "listVerifiedStudentNumbersForAdmin",
69    tag = "credit-registration-admin",
70    params(
71        ("page" = Option<u32>, Query, description = "Page number, from 1"),
72        ("limit" = Option<u32>, Query, description = "Rows per page"),
73        ("verified_via" = Option<StudentNumberVerificationMethod>, Query, description = "How the link was established"),
74        ("search" = Option<String>, Query, description = "Student number, name or email")
75    ),
76    responses(
77        (status = 200, description = "A page of the live links", body = Page<AdminVerifiedStudentNumberRow>)
78    )
79)]
80pub async fn list_verified_student_numbers_for_admin(
81    user: AuthUser,
82    pool: web::Data<PgPool>,
83    query: web::Query<ListVerifiedStudentNumbersQuery>,
84) -> ControllerResult<web::Json<Page<AdminVerifiedStudentNumberRow>>> {
85    let mut conn = pool.acquire().await?;
86    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
87
88    let pagination = parse_pagination(query.page, query.limit, 50)?;
89    let (rows, total_count) = verified_student_numbers::get_admin_page(
90        &mut conn,
91        query.verified_via,
92        expose_option(&query.search),
93        pagination.limit(),
94        pagination.offset(),
95    )
96    .await?;
97    let data = rows.into_iter().map(to_admin_student_number).collect();
98
99    token.authorized_ok(web::Json(Page::new(pagination, data, total_count)))
100}
101
102/**
103POST `/api/v0/main-frontend/credit-registration-admin/student-numbers/{id}/unlink` - Retires one link.
104
105A reason is required, so the request carries a body rather than being a `DELETE`. The row is
106soft-deleted: the number a student once held is part of the audit trail.
107*/
108#[instrument(skip(pool, payload))]
109#[utoipa::path(
110    post,
111    path = "/student-numbers/{verified_student_number_id}/unlink",
112    operation_id = "adminUnlinkStudentNumber",
113    tag = "credit-registration-admin",
114    params(("verified_student_number_id" = Uuid, Path, description = "Verified student number id")),
115    request_body = AdminUnlinkStudentNumberPayload,
116    responses(
117        (status = 200, description = "How many registrations went back to waiting", body = AdminUnlinkStudentNumberResult),
118        (status = 422, description = "No reason given"),
119        (status = 404, description = "No such link")
120    )
121)]
122pub async fn admin_unlink_student_number(
123    user: AuthUser,
124    pool: web::Data<PgPool>,
125    verified_student_number_id: web::Path<Uuid>,
126    payload: web::Json<AdminUnlinkStudentNumberPayload>,
127) -> ControllerResult<web::Json<AdminUnlinkStudentNumberResult>> {
128    let mut conn = pool.acquire().await?;
129    let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
130
131    let reason = required_reason(&payload.reason)?;
132    let id = *verified_student_number_id;
133    let link = verified_student_numbers::get_by_id(&mut conn, id).await?;
134
135    let mut tx = conn.begin().await?;
136    let affected_registration_count = unlink_verified_student_number(
137        &mut tx,
138        id,
139        link.user_id,
140        Some(user.id),
141        CreditRegistrationEventKind::AdminAction,
142        "An administrator unlinked this student number.",
143    )
144    .await?;
145    models::credit_registration_admin_actions::record(
146        &mut tx,
147        &NewCreditRegistrationAdminAction {
148            target_id: Some(id),
149            reason: Some(reason.to_string()),
150            details: Some(serde_json::json!({
151                "user_id": link.user_id,
152                "student_number": link.student_number.expose_secret(),
153                "verified_via": link.verified_via,
154            })),
155            affected_row_count: Some(
156                i32::try_from(affected_registration_count).unwrap_or(i32::MAX),
157            ),
158            ..NewCreditRegistrationAdminAction::new(
159                CreditRegistrationAdminAction::UnlinkStudentNumber,
160                CreditRegistrationAdminActionTarget::VerifiedStudentNumber,
161                user.id,
162                GLOBAL_ADMIN_ROLE,
163            )
164        },
165    )
166    .await?;
167    tx.commit().await?;
168
169    token.authorized_ok(web::Json(AdminUnlinkStudentNumberResult {
170        affected_registration_count,
171    }))
172}
173
174fn to_admin_student_number(row: AdminVerifiedStudentNumber) -> AdminVerifiedStudentNumberRow {
175    AdminVerifiedStudentNumberRow {
176        id: row.id,
177        user_id: row.user_id,
178        user_email: row.user_email,
179        first_name: row.first_name,
180        last_name: row.last_name,
181        student_number: row.student_number.expose_secret().to_owned(),
182        sisu_person_id: expose_option(&row.sisu_person_id).map(str::to_owned),
183        verified_at: row.verified_at,
184        verified_via: row.verified_via,
185        verified_via_email: expose_option(&row.verified_via_email).map(str::to_owned),
186        linked_by_user_id: row.linked_by_user_id,
187        link_reason: row.link_reason,
188        verified_from_course_id: row.verified_from_course_id,
189        live_registration_count: row.live_registration_count,
190    }
191}
192
193pub fn _add_routes(cfg: &mut ServiceConfig) {
194    cfg.route(
195        "/student-numbers",
196        web::get().to(list_verified_student_numbers_for_admin),
197    )
198    .route(
199        "/student-numbers/{verified_student_number_id}/unlink",
200        web::post().to(admin_unlink_student_number),
201    );
202}