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