headless_lms_server/domain/credit_registration_phases/
linking_mail_resend.rs1use std::future::Future;
9use std::pin::Pin;
10
11use serde::{Deserialize, Serialize};
12use utoipa::ToSchema;
13
14use headless_lms_models::course_module_suotar_realisations::{
15 get_active_for_course, listing_request_item_id,
16};
17use headless_lms_models::library::credit_registration::account_linking::{
18 ClaimedLinkingMails, DiscoveredPerson, claim_linking_mails,
19};
20use headless_lms_models::verified_student_numbers;
21use headless_lms_utils::services::suotar::{
22 ListByCourseRequestItem, ResolvePersonRequestItem, SuotarCallContext, SuotarEndpoint,
23 SuotarItemStatus,
24};
25use uuid::Uuid;
26
27use super::{CreditRegistrationPhase, PhaseContext, listed_person_addresses, worker_name};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LinkingMailResendOutcome {
32 Claimed,
34 AlreadyMailedToEveryKnownAddress,
36 RefusedByRateCap,
38 NoAddressInStudyRegistry,
39 NotOnTheCourseRoster,
41 StudyRegistryUnavailable,
43}
44
45pub async fn resend_linking_mail(
47 ctx: &PhaseContext<'_>,
48 course_id: Uuid,
49 student_number: &str,
50) -> anyhow::Result<LinkingMailResendOutcome> {
51 let mut conn = ctx.pool.acquire().await?;
52 let realisations = get_active_for_course(&mut conn, course_id).await?;
53
54 let mut items = Vec::new();
55 for realisation in &realisations {
56 let Some(course_code) = super::enrolment_discovery::listable_course_code(realisation)
57 else {
58 continue;
59 };
60 items.push(ListByCourseRequestItem {
61 request_item_id: listing_request_item_id(realisation.id),
62 course_code,
63 course_unit_realisation_id: Some(realisation.course_unit_realisation_id.clone()),
64 });
65 }
66 if items.is_empty() {
67 return Ok(LinkingMailResendOutcome::NotOnTheCourseRoster);
68 }
69
70 let mut person = None;
72 for chunk in items.chunks(SuotarEndpoint::ListByCourse.max_batch_size()) {
73 let response = ctx
74 .suotar_client
75 .list_enrolments_by_course(
76 SuotarCallContext::new(worker_name(
77 ctx.caller,
78 CreditRegistrationPhase::EnrolmentDiscovery,
79 )),
80 chunk.to_vec(),
81 )
82 .await;
83 let Ok(response) = response else {
84 return Ok(LinkingMailResendOutcome::StudyRegistryUnavailable);
85 };
86 person = response
87 .items
88 .iter()
89 .filter(|item| item.status == SuotarItemStatus::Ok)
90 .filter_map(|item| item.result.as_ref())
91 .flat_map(|result| result.people.iter())
92 .find(|candidate| candidate.student_number == student_number)
93 .cloned();
94 if person.is_some() {
95 break;
96 }
97 }
98 let Some(person) = person else {
99 return Ok(LinkingMailResendOutcome::NotOnTheCourseRoster);
100 };
101
102 let discovered = DiscoveredPerson {
103 sisu_person_id: person.person_id.clone(),
104 student_number: person.student_number.clone(),
105 first_names: Some(person.first_names.clone()),
106 last_name: Some(person.last_name.clone()),
107 course_id,
108 addresses: listed_person_addresses(&person),
109 };
110 if discovered.addresses.is_empty() {
111 return Ok(LinkingMailResendOutcome::NoAddressInStudyRegistry);
112 }
113
114 let ClaimedLinkingMails {
115 claimed,
116 suppressed_by_dedup,
117 suppressed_by_rate_cap,
118 } = claim_linking_mails(&mut conn, &discovered).await?;
119 if claimed > 0 {
120 return Ok(LinkingMailResendOutcome::Claimed);
121 }
122 if suppressed_by_rate_cap > 0 {
123 return Ok(LinkingMailResendOutcome::RefusedByRateCap);
124 }
125 if suppressed_by_dedup > 0 {
126 return Ok(LinkingMailResendOutcome::AlreadyMailedToEveryKnownAddress);
127 }
128 Ok(LinkingMailResendOutcome::NoAddressInStudyRegistry)
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ResendDecision {
134 AlreadyLinked,
136 Attempted(LinkingMailResendOutcome),
137}
138
139pub struct ResendAttempt {
141 pub decision: ResendDecision,
142 pub retired_mail_count: i64,
144}
145
146#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
150#[serde(rename_all = "snake_case")]
151pub enum ResendOutcome {
152 Queued,
154 AlreadyMailedToEveryKnownAddress,
155 RefusedByRateCap,
157 NoAddressInStudyRegistry,
158 NotOnTheCourseRoster,
159 AlreadyLinked,
161 StudyRegistryUnavailable,
162 NoStudentNumberKnown,
164}
165
166impl From<ResendDecision> for ResendOutcome {
167 fn from(decision: ResendDecision) -> Self {
168 match decision {
169 ResendDecision::AlreadyLinked => Self::AlreadyLinked,
170 ResendDecision::Attempted(LinkingMailResendOutcome::Claimed) => Self::Queued,
171 ResendDecision::Attempted(
172 LinkingMailResendOutcome::AlreadyMailedToEveryKnownAddress,
173 ) => Self::AlreadyMailedToEveryKnownAddress,
174 ResendDecision::Attempted(LinkingMailResendOutcome::RefusedByRateCap) => {
175 Self::RefusedByRateCap
176 }
177 ResendDecision::Attempted(LinkingMailResendOutcome::NoAddressInStudyRegistry) => {
178 Self::NoAddressInStudyRegistry
179 }
180 ResendDecision::Attempted(LinkingMailResendOutcome::NotOnTheCourseRoster) => {
181 Self::NotOnTheCourseRoster
182 }
183 ResendDecision::Attempted(LinkingMailResendOutcome::StudyRegistryUnavailable) => {
184 Self::StudyRegistryUnavailable
185 }
186 }
187 }
188}
189
190pub async fn resend_linking_mail_for_target<'a>(
197 ctx: &PhaseContext<'_>,
198 course_id: Uuid,
199 student_number: &str,
200 before_send: Pin<Box<dyn Future<Output = anyhow::Result<i64>> + 'a>>,
201) -> anyhow::Result<ResendAttempt> {
202 let already_linked = {
203 let mut conn = ctx.pool.acquire().await?;
204 verified_student_numbers::get_by_student_number(&mut conn, student_number)
205 .await?
206 .is_some()
207 };
208 if already_linked {
209 return Ok(ResendAttempt {
210 decision: ResendDecision::AlreadyLinked,
211 retired_mail_count: 0,
212 });
213 }
214 let retired_mail_count = before_send.await?;
215 let decision =
216 ResendDecision::Attempted(resend_linking_mail(ctx, course_id, student_number).await?);
217 Ok(ResendAttempt {
218 decision,
219 retired_mail_count,
220 })
221}
222
223pub struct ResolvedPerson {
224 pub sisu_person_id: String,
225 pub first_names: String,
226 pub last_name: String,
227 pub code: String,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum ResolvePersonError {
235 StudyRegistryUnavailable,
237 ItemMissingFromResponse,
239}
240
241pub async fn resolve_person(
246 ctx: &PhaseContext<'_>,
247 student_number: &str,
248) -> Result<Option<ResolvedPerson>, ResolvePersonError> {
249 let request_item_id = format!("admin-{student_number}");
250 let response = ctx
251 .suotar_client
252 .resolve_persons(
253 SuotarCallContext::new(ctx.caller),
254 vec![ResolvePersonRequestItem {
255 request_item_id: request_item_id.clone(),
256 student_number: student_number.to_string(),
257 }],
258 )
259 .await
260 .map_err(|_| ResolvePersonError::StudyRegistryUnavailable)?;
261 let Some(item) = response.item(&request_item_id) else {
262 return Err(ResolvePersonError::ItemMissingFromResponse);
263 };
264 let Some(result) = item.result.as_ref() else {
265 return Ok(None);
266 };
267 Ok(Some(ResolvedPerson {
268 sisu_person_id: result.person_id.clone(),
269 first_names: result.first_names.clone(),
270 last_name: result.last_name.clone(),
271 code: item.code.clone(),
272 }))
273}