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