Skip to main content

headless_lms_server/controllers/main_frontend/course_credit_registrations/
mod.rs

1/*!
2Handlers for HTTP requests to `/api/v0/main-frontend/course-credit-registrations`.
3
4Teachers see the unmasked student number, but recipient addresses are masked to their domain, the
5study registry's own error text is never returned, and nothing here can override a rate cap.
6
7Every handler with a student in it authorizes on `ViewAndManageCreditRegistrations`, which an
8assistant does not hold: `ViewUserProgressOrDetails` and `Edit` would both let a course's assistants —
9often students on the same programme — list and export every classmate's national study registry
10identity. The one exception is the module configuration, which names no student.
11
12Every mutating handler writes exactly one `credit_registration_admin_actions` row with
13`actor_role = 'course_teacher'`, in the transaction that has the effect.
14*/
15
16mod actions;
17mod enrolment_recheck;
18mod export;
19mod retry;
20
21use headless_lms_models::course_modules::CourseModuleCreditRegistrationConfig;
22use headless_lms_models::credit_registration_admin_actions::{
23    COURSE_TEACHER_ROLE, CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
24    NewCreditRegistrationAdminAction,
25};
26use headless_lms_models::credit_registration_events::{
27    CreditRegistrationEventKind, NotImprovedAttainment,
28};
29use headless_lms_models::credit_registrations::{
30    CourseModuleStateCount, CreditRegistrationErrorCode, CreditRegistrationState,
31    ResubmissionRefusal, ResubmissionStrictness, TeacherCreditRegistration,
32    TeacherCreditRegistrationFilters,
33};
34use headless_lms_models::email_deliveries::{EmailSendStatus, EmailSendStatusReport};
35use headless_lms_models::library::credit_registration::account_linking::MAX_LINKING_MAILS_PER_PERSON_AND_COURSE;
36use headless_lms_models::library::credit_registration::student_notifications;
37use headless_lms_models::library::credit_registration::{
38    PendingPreconditions, StudentFacingCreditRegistrationStatus,
39};
40use headless_lms_models::verified_student_numbers::StudentNumberVerificationMethod;
41use headless_lms_models::{
42    credit_registration_account_linking_emails::{self, CreditRegistrationAccountLinkingEmail},
43    verified_student_numbers,
44};
45use headless_lms_utils::secret_string::expose_option;
46use secrecy::{ExposeSecret, SecretString};
47use std::collections::HashMap;
48use utoipa::{OpenApi, ToSchema};
49
50use crate::domain::credit_registration::linking_mail_resend::{
51    ResendOutcome, ensure_resend_possible,
52};
53use crate::prelude::*;
54use headless_lms_base::config::ApplicationConfiguration;
55use headless_lms_credit_registration::account_linking::{
56    ManualActionContext, resend_linking_mail_for_target,
57};
58use headless_lms_utils::services::suotar::SuotarClient;
59
60use crate::domain::credit_registration::enrolment_recheck::can_request_enrolment_recheck;
61use crate::domain::credit_registration::mail_status::{NotificationEmailStatus, mask_email};
62
63/// Every handler here that names a student gates on this; see the module doc for why
64/// `ViewAndManageCreditRegistrations` and not a broader course permission.
65pub(crate) async fn authorize_credit_registration_teacher(
66    conn: &mut PgConnection,
67    user_id: Uuid,
68    course_id: Uuid,
69) -> Result<crate::domain::authorization::AuthorizationToken, ControllerError> {
70    authorize(
71        conn,
72        Act::ViewAndManageCreditRegistrations,
73        Some(user_id),
74        Res::Course(course_id),
75    )
76    .await
77    .map_err(Into::into)
78}
79
80/// A fat-finger guard on top of the per-person caps, which this endpoint cannot relax.
81const MAX_TEACHER_RESENDS_PER_HOUR: i64 = 20;
82
83/// Marks the resend's study registry call in the call log as a manual action, not worker traffic.
84const RESEND_CALLER: &str = "teacher-resend";
85
86/// The by-user-ids lookup is bounded by what the caller names rather than by a page, so the payload
87/// itself has to be bounded. Comfortably above the students tab's page size.
88const MAX_USER_IDS_PER_REQUEST: usize = 500;
89
90/// `MAX_USER_IDS_PER_REQUEST` * a generous per-student attempt count would allow a ~25,000-row join
91/// per request; this is the real ceiling. A course with this many attempts across the named students
92/// needs a narrower request, not a bigger response.
93const MAX_ROWS_PER_REQUEST: i64 = 2_000;
94
95#[derive(OpenApi)]
96#[openapi(paths(
97    get_course_credit_registration_module_configs,
98    get_course_credit_registration_summary,
99    get_course_credit_registrations_for_users,
100    get_course_credit_registrations,
101    get_credit_registration_details,
102    resend_course_credit_registration_linking_email,
103    retry::retry_credit_registration,
104    retry::retry_failed_credit_registrations_for_course,
105    enrolment_recheck::recheck_credit_registration_enrolment,
106    actions::get_course_credit_registration_actions,
107    export::export_course_credit_registrations
108))]
109pub(crate) struct MainFrontendCourseCreditRegistrationsApiDoc;
110
111/// What we can honestly say about a linking mail: our send status and the address's domain.
112#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
113pub struct TeacherLinkingEmailStatus {
114    pub email_send_status: EmailSendStatus,
115    pub sent_at: Option<DateTime<Utc>>,
116    pub last_attempt_at: Option<DateTime<Utc>>,
117    pub retry_count: i32,
118    pub next_retry_at: Option<DateTime<Utc>>,
119    pub emailed_to_masked: String,
120}
121
122#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
123pub struct CourseCreditRegistration {
124    pub id: Uuid,
125    pub user_id: Uuid,
126    pub first_name: Option<String>,
127    pub last_name: Option<String>,
128    pub email: Option<String>,
129    pub course_id: Uuid,
130    pub course_module_id: Uuid,
131    pub course_module_name: Option<String>,
132    pub course_instance_id: Uuid,
133    pub course_module_completion_id: Uuid,
134    pub completion_date: DateTime<Utc>,
135    pub state: CreditRegistrationState,
136    pub state_entered_at: DateTime<Utc>,
137    /// The same collapsed stage the student is shown, so both audiences read one classification.
138    pub student_facing_status: StudentFacingCreditRegistrationStatus,
139    pub error_code: Option<CreditRegistrationErrorCode>,
140    pub needs_admin_attention: bool,
141    pub next_attempt_at: DateTime<Utc>,
142    pub registered_at: Option<DateTime<Utc>>,
143    pub sisu_attainment_id: Option<String>,
144    pub grade_id: Option<String>,
145    pub credits: Option<f32>,
146    pub attempt_number: i32,
147    pub superseded: bool,
148    /// Why a teacher's retry would refuse this row, or `null` if it would put it back on the
149    /// pipeline: what the row's retry control renders from.
150    pub resubmission_refusal: Option<ResubmissionRefusal>,
151    /// Whether the row's "check enrolment again" action is available now; it shares the student's
152    /// button's allowance.
153    pub can_request_enrolment_recheck: bool,
154    /// In full: a masked number cannot be checked against a student card.
155    pub student_number: Option<String>,
156    pub student_number_verified_at: Option<DateTime<Utc>>,
157    /// `admin_manual` means support established the link rather than the student proving it.
158    pub student_number_verified_via: Option<StudentNumberVerificationMethod>,
159    pub enrolment_realisation_name: Option<String>,
160    /// Only where we can join the account to a Sisu person, which needs a link past or present.
161    pub linking_email: Option<TeacherLinkingEmailStatus>,
162    /// The terminal-state mail this row's status has, if one has been queued. Same derivation the
163    /// student sees, so a teacher answering "did they hear from you" cannot be told something else.
164    pub notification_email: Option<NotificationEmailStatus>,
165}
166
167/// One module's live registrations, split so a teacher can add the columns up.
168///
169/// `registered_count`, `in_progress_count`, `waiting_on_student_count`, `failed_count` and
170/// `not_registering_count` partition `registration_count`: every live row falls in exactly one, and
171/// each is the same classification the row's own badge shows. `needs_admin_attention_count` is not
172/// one of them — it cuts across all five — so it is never added to them.
173#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
174pub struct CourseCreditRegistrationModuleSummary {
175    pub course_module_id: Uuid,
176    pub course_module_name: Option<String>,
177    pub enabled: bool,
178    pub paused: bool,
179    /// Live registrations of this module, replaced attempts excluded. Registrations, not
180    /// completions: one per student per module, and a regrade replaces rather than adds.
181    pub registration_count: i64,
182    /// The credit exists in the study registry, whoever put it there.
183    pub registered_count: i64,
184    /// The pipeline is working on it and nobody has to do anything.
185    pub in_progress_count: i64,
186    /// Waiting for the student: their completion, their student number or their enrolment.
187    pub waiting_on_student_count: i64,
188    pub failed_count: i64,
189    /// Blocked, cancelled or held until the course's settings are fixed: nothing is moving.
190    pub not_registering_count: i64,
191    /// Rows the pipeline handed to support. Nothing for a teacher to do; shown so a module's
192    /// failures do not read as unattended.
193    pub needs_admin_attention_count: i64,
194}
195
196/// The teacher-facing credit registration overview of one course. Both student-number totals are
197/// zero unless some module of the course registers credits.
198#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
199pub struct CourseCreditRegistrationSummary {
200    pub modules: Vec<CourseCreditRegistrationModuleSummary>,
201    /// Course-wide, whatever the per-module counts were narrowed to: enrolled students we hold no
202    /// student number for.
203    pub unlinked_enrolled_student_count: i64,
204    /// Of the unlinked enrolled students, the ones whose linking mail we never managed to hand over.
205    pub linking_emails_failed_to_send_count: i64,
206}
207
208#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
209pub struct CourseCreditRegistrationsPage {
210    pub data: Vec<CourseCreditRegistration>,
211    pub total_pages: u32,
212}
213
214/// Body for the students-tab batch: the users of the current identity-list page.
215#[derive(Debug, Deserialize, ToSchema)]
216pub struct CourseCreditRegistrationUserIdsPayload {
217    pub user_ids: Vec<Uuid>,
218    pub course_instance_id: Option<Uuid>,
219}
220
221#[derive(Debug, Deserialize)]
222pub struct GetCourseCreditRegistrationsQuery {
223    page: Option<u32>,
224    limit: Option<u32>,
225    search: Option<SecretString>,
226    state: Option<CreditRegistrationState>,
227    status: Option<Vec<StudentFacingCreditRegistrationStatus>>,
228    course_instance_id: Option<Uuid>,
229}
230
231/// One event of the item timeline, without the stored request and response bodies: those are the
232/// admin dashboard's, and the study registry's own wording is never rendered.
233#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
234pub struct CourseCreditRegistrationEvent {
235    pub id: Uuid,
236    pub created_at: DateTime<Utc>,
237    pub kind: CreditRegistrationEventKind,
238    pub from_state: Option<CreditRegistrationState>,
239    pub to_state: Option<CreditRegistrationState>,
240    pub error_code: Option<CreditRegistrationErrorCode>,
241    /// Our own wording, written by the pipeline or by whoever acted.
242    pub message: Option<String>,
243    pub actor_user_id: Option<Uuid>,
244}
245
246#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
247pub struct CreditRegistrationDetails {
248    pub course_id: Uuid,
249    pub course_name: String,
250    pub registration: CourseCreditRegistration,
251    /// Every attempt for the same completion, newest first, this one included.
252    pub attempts: Vec<CourseCreditRegistration>,
253    pub events: Vec<CourseCreditRegistrationEvent>,
254    /// The grade the registry already held, for a row it declined as no improvement. The
255    /// registration's own grade is what we sent.
256    pub not_improved_attainment: Option<NotImprovedAttainment>,
257}
258
259#[derive(Debug, Deserialize, ToSchema)]
260pub struct ResendLinkingEmailPayload {
261    /// One of the two names the person; `user_id` only resolves for an account that has held a number.
262    pub user_id: Option<Uuid>,
263    #[schema(value_type = Option<String>)]
264    pub student_number: Option<SecretString>,
265    pub reason: Option<String>,
266}
267
268#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
269pub struct ResendLinkingEmailResult {
270    pub outcome: ResendOutcome,
271    /// The latest mail for this person and course after the attempt, whatever the outcome.
272    pub linking_email: Option<TeacherLinkingEmailStatus>,
273    pub mails_sent_for_this_course: i64,
274    pub max_mails_per_person_and_course: i64,
275}
276
277/**
278GET `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/module-configs` - The
279course's per-module credit registration configuration.
280*/
281#[instrument(skip(pool))]
282#[utoipa::path(
283    get,
284    path = "/courses/{course_id}/module-configs",
285    operation_id = "getCourseCreditRegistrationModuleConfigs",
286    tag = "course-credit-registrations",
287    params(("course_id" = Uuid, Path, description = "Course id")),
288    responses(
289        (status = 200, description = "Every module of the course with its Suotar configuration", body = Vec<CourseModuleCreditRegistrationConfig>)
290    )
291)]
292pub async fn get_course_credit_registration_module_configs(
293    user: AuthUser,
294    pool: web::Data<PgPool>,
295    course_id: web::Path<Uuid>,
296) -> ControllerResult<web::Json<Vec<CourseModuleCreditRegistrationConfig>>> {
297    let mut conn = pool.acquire().await?;
298    let token = authorize(
299        &mut conn,
300        Act::ViewInternalCourseStructure,
301        Some(user.id),
302        Res::Course(*course_id),
303    )
304    .await?;
305
306    let modules =
307        models::course_modules::get_credit_registration_configs_by_course_id(&mut conn, *course_id)
308            .await?;
309    token.authorized_ok(web::Json(modules))
310}
311
312/// One count group's stage, the same classification the group's own rows carry.
313fn stage_of(group: &CourseModuleStateCount) -> StudentFacingCreditRegistrationStatus {
314    StudentFacingCreditRegistrationStatus::of(
315        group.state,
316        PendingPreconditions {
317            completion_eligible: group.completion_eligible,
318            has_verified_student_number: group.has_verified_student_number,
319            course_code_allowed: group.course_code_allowed,
320        },
321        group.enrolment_resolved,
322    )
323}
324
325#[derive(Debug, Deserialize)]
326pub struct CourseCreditRegistrationSummaryQuery {
327    course_instance_id: Option<Uuid>,
328}
329
330/**
331GET `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/summary` - Per-module
332counts plus the two reasons a student of this course will not get credits.
333
334Course-wide unless `course_instance_id` narrows the per-module counts to one instance. The two
335student-number totals are course-wide either way: a student holds one number, not one per instance.
336*/
337#[instrument(skip(pool))]
338#[utoipa::path(
339    get,
340    path = "/courses/{course_id}/summary",
341    operation_id = "getCourseCreditRegistrationSummary",
342    tag = "course-credit-registrations",
343    params(
344        ("course_id" = Uuid, Path, description = "Course id"),
345        ("course_instance_id" = Option<Uuid>, Query, description = "Narrows the per-module counts to one instance")
346    ),
347    responses(
348        (status = 200, description = "The course's credit registration summary", body = CourseCreditRegistrationSummary)
349    )
350)]
351pub async fn get_course_credit_registration_summary(
352    user: AuthUser,
353    pool: web::Data<PgPool>,
354    course_id: web::Path<Uuid>,
355    query: web::Query<CourseCreditRegistrationSummaryQuery>,
356) -> ControllerResult<web::Json<CourseCreditRegistrationSummary>> {
357    let mut conn = pool.acquire().await?;
358    let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
359
360    let configs =
361        models::course_modules::get_credit_registration_configs_by_course_id(&mut conn, *course_id)
362            .await?;
363    let on_push_path = configs
364        .iter()
365        .any(|config| config.enable_credit_registration_via_suotar);
366    let module_names: HashMap<Uuid, Option<String>> =
367        models::course_modules::get_by_course_id(&mut conn, *course_id)
368            .await?
369            .into_iter()
370            .map(|module| (module.id, module.name))
371            .collect();
372    let counts = models::credit_registrations::count_by_module_and_state_for_course(
373        &mut conn,
374        *course_id,
375        query.course_instance_id,
376    )
377    .await?;
378
379    use StudentFacingCreditRegistrationStatus as Stage;
380    let modules = configs
381        .into_iter()
382        .map(|config| {
383            let groups: Vec<&CourseModuleStateCount> = counts
384                .iter()
385                .filter(|group| group.course_module_id == config.course_module_id)
386                .collect();
387            let mut by_stage: HashMap<Stage, i64> = HashMap::new();
388            for group in &groups {
389                *by_stage.entry(stage_of(group)).or_default() += group.count;
390            }
391            let in_stage = |stage: Stage| -> i64 { by_stage.get(&stage).copied().unwrap_or(0) };
392            CourseCreditRegistrationModuleSummary {
393                course_module_id: config.course_module_id,
394                course_module_name: module_names
395                    .get(&config.course_module_id)
396                    .cloned()
397                    .unwrap_or_default(),
398                enabled: config.enable_credit_registration_via_suotar,
399                paused: config.credit_registration_paused_at.is_some(),
400                registration_count: groups.iter().map(|group| group.count).sum(),
401                registered_count: in_stage(Stage::Registered),
402                in_progress_count: in_stage(Stage::LookingForEnrolment)
403                    + in_stage(Stage::Sending)
404                    + in_stage(Stage::WaitingForSisu),
405                waiting_on_student_count: in_stage(Stage::WaitingForCompletion)
406                    + in_stage(Stage::NeedsStudentNumber)
407                    + in_stage(Stage::NeedsEnrolment),
408                failed_count: in_stage(Stage::Failed),
409                not_registering_count: in_stage(Stage::NotRegistering)
410                    + in_stage(Stage::WaitingForCourseSetup),
411                needs_admin_attention_count: groups
412                    .iter()
413                    .map(|group| group.needs_admin_attention_count)
414                    .sum(),
415            }
416        })
417        .collect();
418
419    // On a course that registers nothing these would report the whole roster as a backlog.
420    let unlinked_enrolled_student_count = if on_push_path {
421        verified_student_numbers::count_unlinked_enrolled_students_for_course(&mut conn, *course_id)
422            .await?
423    } else {
424        0
425    };
426    let linking_emails_failed_to_send_count = if on_push_path {
427        count_failed_linking_emails(&mut conn, *course_id).await?
428    } else {
429        0
430    };
431
432    token.authorized_ok(web::Json(CourseCreditRegistrationSummary {
433        modules,
434        unlinked_enrolled_student_count,
435        linking_emails_failed_to_send_count,
436    }))
437}
438
439/**
440POST `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/by-user-ids` - The
441registrations of the named students, for the students tab's current page.
442*/
443#[instrument(skip(pool, payload))]
444#[utoipa::path(
445    post,
446    path = "/courses/{course_id}/by-user-ids",
447    operation_id = "getCourseCreditRegistrationsForUsers",
448    tag = "course-credit-registrations",
449    params(("course_id" = Uuid, Path, description = "Course id")),
450    request_body = CourseCreditRegistrationUserIdsPayload,
451    responses(
452        (status = 200, description = "The named students' registrations", body = Vec<CourseCreditRegistration>)
453    )
454)]
455pub async fn get_course_credit_registrations_for_users(
456    user: AuthUser,
457    pool: web::Data<PgPool>,
458    course_id: web::Path<Uuid>,
459    payload: web::Json<CourseCreditRegistrationUserIdsPayload>,
460) -> ControllerResult<web::Json<Vec<CourseCreditRegistration>>> {
461    let mut conn = pool.acquire().await?;
462    let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
463
464    if payload.user_ids.len() > MAX_USER_IDS_PER_REQUEST {
465        return Err(controller_err!(
466            BadRequest,
467            format!("Name at most {MAX_USER_IDS_PER_REQUEST} students per request.")
468        ));
469    }
470    let rows = models::credit_registrations::get_teacher_facing_by_course_id(
471        &mut conn,
472        *course_id,
473        &TeacherCreditRegistrationFilters {
474            user_ids: Some(&payload.user_ids),
475            course_instance_id: payload.course_instance_id,
476            ..TeacherCreditRegistrationFilters::default()
477        },
478        // Every attempt of every named student, not a page: the tab renders one cell per student and
479        // a student's superseded attempts belong in it. Bounded by the cap above rather than by a
480        // page size, which is why the sibling endpoints' `Pagination` does not apply. Queried one row
481        // over the real cap so exceeding it is detectable rather than silently truncated.
482        MAX_ROWS_PER_REQUEST + 1,
483        0,
484    )
485    .await?;
486    if rows.len() as i64 > MAX_ROWS_PER_REQUEST {
487        return Err(controller_err!(
488            BadRequest,
489            format!(
490                "This request would return more than {MAX_ROWS_PER_REQUEST} registration rows. Name fewer students per request."
491            )
492        ));
493    }
494    let res = build_teacher_registrations(&mut conn, *course_id, rows).await?;
495
496    token.authorized_ok(web::Json(res))
497}
498
499/**
500GET `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/list` - A page of the
501course's registrations, filtered by state and searched by student name, email or student number.
502*/
503#[instrument(skip(pool))]
504#[utoipa::path(
505    get,
506    path = "/courses/{course_id}/list",
507    operation_id = "getCourseCreditRegistrations",
508    tag = "course-credit-registrations",
509    params(
510        ("course_id" = Uuid, Path, description = "Course id"),
511        ("page" = Option<u32>, Query, description = "Page number, from 1"),
512        ("limit" = Option<u32>, Query, description = "Rows per page"),
513        ("search" = Option<String>, Query, description = "Student name, email or student number"),
514        ("state" = Option<CreditRegistrationState>, Query, description = "Ledger state filter"),
515        ("status" = Option<Vec<StudentFacingCreditRegistrationStatus>>, Query, description = "Student-facing stage filter; repeat the parameter for several"),
516        ("course_instance_id" = Option<Uuid>, Query, description = "Course instance filter")
517    ),
518    responses(
519        (status = 200, description = "A page of the course's registrations", body = CourseCreditRegistrationsPage)
520    )
521)]
522pub async fn get_course_credit_registrations(
523    user: AuthUser,
524    pool: web::Data<PgPool>,
525    course_id: web::Path<Uuid>,
526    query: MultiQuery<GetCourseCreditRegistrationsQuery>,
527) -> ControllerResult<web::Json<CourseCreditRegistrationsPage>> {
528    let mut conn = pool.acquire().await?;
529    let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
530
531    let pagination = parse_pagination(query.page, query.limit, 100)?;
532    let search = non_empty(expose_option(&query.search));
533    let filters = TeacherCreditRegistrationFilters {
534        state: query.state,
535        stages: query.status.as_deref().unwrap_or_default(),
536        search,
537        course_instance_id: query.course_instance_id,
538        ..TeacherCreditRegistrationFilters::default()
539    };
540    let total = models::credit_registrations::count_teacher_facing_by_course_id(
541        &mut conn, *course_id, &filters,
542    )
543    .await?;
544    let rows = models::credit_registrations::get_teacher_facing_by_course_id(
545        &mut conn,
546        *course_id,
547        &filters,
548        pagination.limit(),
549        pagination.offset(),
550    )
551    .await?;
552    let data = build_teacher_registrations(&mut conn, *course_id, rows).await?;
553
554    token.authorized_ok(web::Json(CourseCreditRegistrationsPage {
555        data,
556        total_pages: pagination.total_pages(u32::try_from(total).unwrap_or(u32::MAX)),
557    }))
558}
559
560/**
561GET `/api/v0/main-frontend/course-credit-registrations/registrations/{credit_registration_id}` - One
562registration with its timeline and the other attempts for the same completion.
563
564Authorized on the row's own course: a course id from the caller would let a teacher of one course pair
565it with a foreign registration id.
566*/
567#[instrument(skip(pool))]
568#[utoipa::path(
569    get,
570    path = "/registrations/{credit_registration_id}",
571    operation_id = "getCreditRegistrationDetails",
572    tag = "course-credit-registrations",
573    params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
574    responses(
575        (status = 200, description = "The registration with its timeline", body = CreditRegistrationDetails),
576        (status = 404, description = "No such registration")
577    )
578)]
579pub async fn get_credit_registration_details(
580    user: AuthUser,
581    pool: web::Data<PgPool>,
582    credit_registration_id: web::Path<Uuid>,
583) -> ControllerResult<web::Json<CreditRegistrationDetails>> {
584    let mut conn = pool.acquire().await?;
585    let row =
586        models::credit_registrations::get_teacher_facing_by_id(&mut conn, *credit_registration_id)
587            .await?
588            .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
589    let token = authorize_credit_registration_teacher(&mut conn, user.id, row.course_id).await?;
590
591    let course = models::courses::get_course(&mut conn, row.course_id).await?;
592    let registration_id = row.id;
593    let attempt_rows =
594        models::credit_registrations::get_teacher_facing_attempts_for_completion(&mut conn, &row)
595            .await?;
596    // `attempt_rows` already contains `row`, so it is picked out of `attempts` rather than fetched
597    // a second time.
598    let attempts = build_teacher_registrations(&mut conn, row.course_id, attempt_rows).await?;
599    let registration = attempts
600        .iter()
601        .find(|attempt| attempt.id == registration_id)
602        .cloned()
603        .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
604    let events = models::credit_registration_events::get_by_registration_id(
605        &mut conn,
606        *credit_registration_id,
607    )
608    .await?
609    .into_iter()
610    .map(|event| CourseCreditRegistrationEvent {
611        id: event.id,
612        created_at: event.created_at,
613        kind: event.kind,
614        from_state: event.from_state,
615        to_state: event.to_state,
616        error_code: event.error_code,
617        message: event.message,
618        actor_user_id: event.actor_user_id,
619    })
620    .collect();
621
622    let not_improved_attainment = models::credit_registration_events::get_not_improved_attainment(
623        &mut conn,
624        *credit_registration_id,
625    )
626    .await?;
627
628    token.authorized_ok(web::Json(CreditRegistrationDetails {
629        course_id: course.id,
630        course_name: course.name,
631        registration,
632        attempts,
633        events,
634        not_improved_attainment,
635    }))
636}
637
638/**
639POST
640`/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/resend-linking-email` - Sets off
641another account-linking mail for one person on this course.
642
643The target has to be on this course's roster in the study registry and hold no link with us. The caps
644of the ordinary claim path apply and nothing here relaxes them.
645*/
646#[instrument(skip(pool, payload, app_conf, suotar_client))]
647#[utoipa::path(
648    post,
649    path = "/courses/{course_id}/resend-linking-email",
650    operation_id = "resendCourseCreditRegistrationLinkingEmail",
651    tag = "course-credit-registrations",
652    params(("course_id" = Uuid, Path, description = "Course id")),
653    request_body = ResendLinkingEmailPayload,
654    responses(
655        (status = 200, description = "What the attempt did", body = ResendLinkingEmailResult),
656        (status = 400, description = "Nothing named, or this teacher has set off too many mails this hour")
657    )
658)]
659pub async fn resend_course_credit_registration_linking_email(
660    user: AuthUser,
661    pool: web::Data<PgPool>,
662    course_id: web::Path<Uuid>,
663    payload: web::Json<ResendLinkingEmailPayload>,
664    app_conf: web::Data<ApplicationConfiguration>,
665    suotar_client: web::Data<SuotarClient>,
666) -> ControllerResult<web::Json<ResendLinkingEmailResult>> {
667    let mut conn = pool.acquire().await?;
668    let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
669    ensure_resend_possible(&mut conn, &app_conf, *course_id).await?;
670
671    let recent = models::credit_registration_admin_actions::count_by_actor_since(
672        &mut conn,
673        user.id,
674        CreditRegistrationAdminAction::ResendLinkEmail,
675        Utc::now() - chrono::Duration::hours(1),
676    )
677    .await?;
678    if recent >= MAX_TEACHER_RESENDS_PER_HOUR {
679        return Err(controller_err!(
680            BadRequest,
681            "You have sent too many confirmation emails in the last hour. Try again later."
682                .to_string()
683        ));
684    }
685
686    let student_number = resolve_resend_target(&mut conn, *course_id, &payload).await?;
687    let Some(student_number) = student_number else {
688        return finish_resend(
689            &mut conn,
690            &user,
691            *course_id,
692            &payload,
693            None,
694            ResendOutcome::NoStudentNumberKnown,
695            token,
696        )
697        .await;
698    };
699
700    let ctx = ManualActionContext::new(&pool, &suotar_client, RESEND_CALLER);
701    // Released first: the call below takes connections of its own and can hold the request for the
702    // whole Suotar timeout, so keeping this one would tie up three of the pool per resend.
703    drop(conn);
704    info!(actor = %user.id, course_id = %*course_id, "Teacher requested a linking mail resend");
705    let attempt = resend_linking_mail_for_target(&ctx, *course_id, &student_number, None).await?;
706    let mut conn = pool.acquire().await?;
707    let outcome = ResendOutcome::from(attempt.outcome);
708    info!(?outcome, "Teacher linking mail resend finished");
709
710    finish_resend(
711        &mut conn,
712        &user,
713        *course_id,
714        &payload,
715        Some(&student_number),
716        outcome,
717        token,
718    )
719    .await
720}
721
722/// The person the body names, as a student number. `None` when the account has never held one.
723///
724/// A `user_id` is only answered for an account with a registration on `course_id`. The caller is
725/// authorized on the course and the study registry roster is only consulted later, so without this one
726/// course's teacher could hand in any account's uuid and read off, from which outcome came back,
727/// whether that account holds a verified student number.
728async fn resolve_resend_target(
729    conn: &mut PgConnection,
730    course_id: Uuid,
731    payload: &ResendLinkingEmailPayload,
732) -> Result<Option<SecretString>, ControllerError> {
733    if let Some(student_number) = non_empty(expose_option(&payload.student_number)) {
734        return Ok(Some(SecretString::from(student_number)));
735    }
736    let Some(user_id) = payload.user_id else {
737        return Err(controller_err!(
738            BadRequest,
739            "Name either a user or a student number.".to_string()
740        ));
741    };
742    if !models::credit_registrations::exists_for_user_and_course(conn, user_id, course_id).await? {
743        // Deliberately the same answer for an account that does not exist: the two must not be
744        // distinguishable.
745        return Err(controller_err!(
746            BadRequest,
747            "That account has no credit registration on this course.".to_string()
748        ));
749    }
750    Ok(
751        verified_student_numbers::get_latest_including_deleted_by_user_id(conn, user_id)
752            .await?
753            .map(|link| link.student_number.into()),
754    )
755}
756
757/// Audits the attempt whatever it did, and reports where the person's linking mail now stands.
758async fn finish_resend(
759    conn: &mut PgConnection,
760    user: &AuthUser,
761    course_id: Uuid,
762    payload: &ResendLinkingEmailPayload,
763    student_number: Option<&SecretString>,
764    outcome: ResendOutcome,
765    token: crate::domain::authorization::AuthorizationToken,
766) -> ControllerResult<web::Json<ResendLinkingEmailResult>> {
767    let (mails, mails_sent_for_this_course) = record_resend_and_fetch_mails(
768        conn,
769        course_id,
770        student_number.map(ExposeSecret::expose_secret),
771        user.id,
772        COURSE_TEACHER_ROLE,
773        Some(course_id),
774        payload.reason.clone(),
775        serde_json::json!({
776            "outcome": outcome,
777            "student_number": student_number.map(ExposeSecret::expose_secret),
778        }),
779    )
780    .await?;
781    let linking_email = match mails.first() {
782        Some(mail) => latest_linking_email_status(conn, mail).await?,
783        None => None,
784    };
785
786    token.authorized_ok(web::Json(ResendLinkingEmailResult {
787        outcome,
788        linking_email,
789        mails_sent_for_this_course,
790        max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
791    }))
792}
793
794/// Records a `ResendLinkEmail` action against the course and fetches this student's linking mails
795/// for it. Shared by the teacher and admin resend endpoints, which differ only in who they blame it
796/// on, whether they widen the action to the whole course (`actor_course_id`), and what extra detail
797/// goes into `details`.
798#[allow(clippy::too_many_arguments)]
799pub(crate) async fn record_resend_and_fetch_mails(
800    conn: &mut PgConnection,
801    course_id: Uuid,
802    student_number: Option<&str>,
803    actor_user_id: Uuid,
804    actor_role: &str,
805    actor_course_id: Option<Uuid>,
806    reason: Option<String>,
807    details: serde_json::Value,
808) -> Result<(Vec<CreditRegistrationAccountLinkingEmail>, i64), ControllerError> {
809    models::credit_registration_admin_actions::record(
810        conn,
811        &NewCreditRegistrationAdminAction {
812            target_id: Some(course_id),
813            actor_course_id,
814            reason,
815            details: Some(details),
816            ..NewCreditRegistrationAdminAction::new(
817                CreditRegistrationAdminAction::ResendLinkEmail,
818                CreditRegistrationAdminActionTarget::Course,
819                actor_user_id,
820                actor_role,
821            )
822        },
823    )
824    .await?;
825
826    let mails = match student_number {
827        Some(number) => {
828            credit_registration_account_linking_emails::get_by_course_id_and_student_number(
829                conn, course_id, number,
830            )
831            .await?
832        }
833        None => Vec::new(),
834    };
835    let mails_sent_for_this_course = mails.len() as i64;
836    Ok((mails, mails_sent_for_this_course))
837}
838
839async fn latest_linking_email_status(
840    conn: &mut PgConnection,
841    mail: &CreditRegistrationAccountLinkingEmail,
842) -> Result<Option<TeacherLinkingEmailStatus>, ControllerError> {
843    let reports =
844        credit_registration_account_linking_emails::get_send_status_reports(conn, &[mail.id])
845            .await?;
846    Ok(reports
847        .get(&mail.id)
848        .map(|report| linking_email_status_of(report, mail)))
849}
850
851fn linking_email_status_of(
852    report: &EmailSendStatusReport,
853    mail: &CreditRegistrationAccountLinkingEmail,
854) -> TeacherLinkingEmailStatus {
855    TeacherLinkingEmailStatus {
856        email_send_status: report.email_send_status,
857        sent_at: report.sent_at,
858        last_attempt_at: report.last_attempt_at,
859        retry_count: report.retry_count,
860        next_retry_at: report.next_retry_at,
861        emailed_to_masked: mask_email(mail.emailed_to.expose_secret()),
862    }
863}
864
865/// The newest linking mail's send status for each row waiting for a number, by row id. A fixed number
866/// of queries whatever the page holds, because only the listed people are looked up.
867async fn linking_email_statuses(
868    conn: &mut PgConnection,
869    course_id: Uuid,
870    waiting: &[&TeacherCreditRegistration],
871) -> Result<HashMap<Uuid, TeacherLinkingEmailStatus>, ControllerError> {
872    if waiting.is_empty() {
873        return Ok(HashMap::new());
874    }
875    let need_lookup: Vec<Uuid> = waiting
876        .iter()
877        .filter(|row| row.sisu_person_id.is_none())
878        .map(|row| row.user_id)
879        .collect();
880    let latest_links: HashMap<Uuid, String> = if need_lookup.is_empty() {
881        HashMap::new()
882    } else {
883        verified_student_numbers::get_latest_including_deleted_by_user_ids(conn, &need_lookup)
884            .await?
885            .into_iter()
886            .filter_map(|link| {
887                let person_id = link.sisu_person_id?.expose_secret().to_owned();
888                Some((link.user_id, person_id))
889            })
890            .collect()
891    };
892    let per_row: Vec<(Uuid, String)> = waiting
893        .iter()
894        .filter_map(|row| {
895            let person_id = row
896                .sisu_person_id
897                .as_ref()
898                .map(|id| id.expose_secret().to_owned())
899                .or_else(|| latest_links.get(&row.user_id).cloned())?;
900            Some((row.id, person_id))
901        })
902        .collect();
903    if per_row.is_empty() {
904        return Ok(HashMap::new());
905    }
906    let person_ids: Vec<String> = per_row
907        .iter()
908        .map(|(_, person_id)| person_id.clone())
909        .collect();
910    let mails = credit_registration_account_linking_emails::get_latest_by_course_and_persons(
911        conn,
912        course_id,
913        &person_ids,
914    )
915    .await?;
916    let matched: Vec<(Uuid, &CreditRegistrationAccountLinkingEmail)> = per_row
917        .iter()
918        .filter_map(|(row_id, person_id)| Some((*row_id, mails.get(person_id)?)))
919        .collect();
920    if matched.is_empty() {
921        return Ok(HashMap::new());
922    }
923    let mail_ids: Vec<Uuid> = matched.iter().map(|(_, mail)| mail.id).collect();
924    let reports =
925        credit_registration_account_linking_emails::get_send_status_reports(conn, &mail_ids)
926            .await?;
927    Ok(matched
928        .into_iter()
929        .filter_map(|(row_id, mail)| {
930            let report = reports.get(&mail.id)?;
931            Some((row_id, linking_email_status_of(report, mail)))
932        })
933        .collect())
934}
935
936/// Enriches the ledger rows with the linking-mail status. A row only gets one when the account holds —
937/// or once held — a link, because the mail is addressed to a Sisu person.
938///
939/// Shared with the csv export so the file and the table cannot disagree about a row's status or
940/// about how much of an address is shown.
941pub(crate) async fn build_teacher_registrations(
942    conn: &mut PgConnection,
943    course_id: Uuid,
944    rows: Vec<TeacherCreditRegistration>,
945) -> Result<Vec<CourseCreditRegistration>, ControllerError> {
946    let waiting: Vec<&TeacherCreditRegistration> = rows
947        .iter()
948        .filter(|row| {
949            StudentFacingCreditRegistrationStatus::of(
950                row.state,
951                row.preconditions(),
952                row.enrolment_resolved,
953            ) == StudentFacingCreditRegistrationStatus::NeedsStudentNumber
954        })
955        .collect();
956    let mut statuses = linking_email_statuses(conn, course_id, &waiting).await?;
957    let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
958    let notification_mails = student_notifications::get_for_registrations(conn, &ids).await?;
959    Ok(rows
960        .into_iter()
961        .map(|row| {
962            let linking_email = statuses.remove(&row.id);
963            // The teacher's own retry strictness, so the row says exactly what that button would do.
964            let resubmission_refusal = row
965                .resubmission_facts()
966                .resubmission_refusal(ResubmissionStrictness::OnlyFailedPermanent);
967            let state = row.state;
968            let base = CourseCreditRegistration::from(row);
969            let notification_email =
970                NotificationEmailStatus::for_state(state, base.id, &notification_mails);
971            CourseCreditRegistration {
972                linking_email,
973                notification_email,
974                resubmission_refusal,
975                ..base
976            }
977        })
978        .collect())
979}
980
981impl From<TeacherCreditRegistration> for CourseCreditRegistration {
982    fn from(row: TeacherCreditRegistration) -> Self {
983        let can_request_enrolment_recheck = can_request_enrolment_recheck(
984            row.state,
985            row.enrolment_check_requested_at,
986            row.enrolment_checked_at,
987        );
988        Self {
989            can_request_enrolment_recheck,
990            student_facing_status: StudentFacingCreditRegistrationStatus::of(
991                row.state,
992                row.preconditions(),
993                row.enrolment_resolved,
994            ),
995            superseded: row.superseded_by_id.is_some(),
996            linking_email: None,
997            notification_email: None,
998            resubmission_refusal: None,
999            id: row.id,
1000            user_id: row.user_id,
1001            first_name: row.first_name,
1002            last_name: row.last_name,
1003            email: row.email,
1004            course_id: row.course_id,
1005            course_module_id: row.course_module_id,
1006            course_module_name: row.course_module_name,
1007            course_instance_id: row.course_instance_id,
1008            course_module_completion_id: row.course_module_completion_id,
1009            completion_date: row.completion_date,
1010            state: row.state,
1011            state_entered_at: row.state_entered_at,
1012            error_code: row.error_code,
1013            needs_admin_attention: row.needs_admin_attention,
1014            next_attempt_at: row.next_attempt_at,
1015            registered_at: row.registered_at,
1016            sisu_attainment_id: row.sisu_attainment_id,
1017            grade_id: row.grade_id,
1018            credits: row.credits,
1019            attempt_number: row.attempt_number,
1020            student_number: expose_option(&row.student_number).map(str::to_owned),
1021            student_number_verified_at: row.student_number_verified_at,
1022            student_number_verified_via: row.student_number_verified_via,
1023            enrolment_realisation_name: row.enrolment_realisation_name,
1024        }
1025    }
1026}
1027
1028/// Linking mails of this course we could not hand over at all.
1029async fn count_failed_linking_emails(
1030    conn: &mut PgConnection,
1031    course_id: Uuid,
1032) -> Result<i64, ControllerError> {
1033    Ok(
1034        credit_registration_account_linking_emails::count_send_failed_for_course(
1035            conn,
1036            course_id,
1037            Utc::now(),
1038        )
1039        .await?,
1040    )
1041}
1042
1043pub fn _add_routes(cfg: &mut ServiceConfig) {
1044    cfg.route(
1045        "/courses/{course_id}/module-configs",
1046        web::get().to(get_course_credit_registration_module_configs),
1047    )
1048    .route(
1049        "/courses/{course_id}/summary",
1050        web::get().to(get_course_credit_registration_summary),
1051    )
1052    .route(
1053        "/courses/{course_id}/by-user-ids",
1054        web::post().to(get_course_credit_registrations_for_users),
1055    )
1056    .route(
1057        "/courses/{course_id}/list",
1058        web::get().to(get_course_credit_registrations),
1059    )
1060    .route(
1061        "/courses/{course_id}/resend-linking-email",
1062        web::post().to(resend_course_credit_registration_linking_email),
1063    )
1064    .route(
1065        "/registrations/{credit_registration_id}",
1066        web::get().to(get_credit_registration_details),
1067    );
1068    retry::_add_routes(cfg);
1069    enrolment_recheck::_add_routes(cfg);
1070    actions::_add_routes(cfg);
1071    export::_add_routes(cfg);
1072}