1use headless_lms_models::course_module_suotar_realisations::{
10 RealisationListingOutcome, RealisationToList, claim_stalest_for_listing,
11 listing_request_item_id, mark_listing_failed, record_listing_outcome,
12};
13use headless_lms_models::credit_registration_events::scrub_text;
14use headless_lms_models::credit_registration_phase_state::PhaseRunOutcome;
15use headless_lms_models::credit_registrations::{
16 CreditRegistrationErrorCode, recheck_no_usable_enrolment_now,
17};
18use headless_lms_models::email_deliveries::insert_email_delivery_with_placeholders;
19use headless_lms_models::email_templates::EmailTemplateType;
20use headless_lms_models::library::credit_registration::account_linking::{
21 DiscoveredPerson, claim_linking_mails_batch,
22};
23use headless_lms_models::library::credit_registration::classification::map_code;
24use headless_lms_models::library::credit_registration::fast_track::{
25 FastTrackCandidate, FastTrackDecision, FastTrackLink, FastTrackLookup, RegistryName,
26 decide_fast_track, find_fast_track_candidate, find_fast_track_candidates, link_by_email_match,
27};
28use headless_lms_models::verified_student_numbers;
29use headless_lms_utils::error::util_error::UtilError;
30use headless_lms_utils::prelude::BackendError;
31use headless_lms_utils::prelude::Utc;
32use headless_lms_utils::services::suotar::{
33 ListByCourseRequestItem, ListedPerson, SuotarCallContext, SuotarEndpoint, SuotarItemStatus,
34};
35use serde_json::json;
36use sqlx::{Connection, PgConnection};
37use std::collections::{HashMap, HashSet};
38
39use super::{
40 CreditRegistrationPhase, PhaseContext, PhaseScope, TemplateCache,
41 every_item_failed_transiently, listed_person_addresses, template_language,
42};
43
44pub async fn run(ctx: &PhaseContext<'_>, scope: &PhaseScope) -> anyhow::Result<PhaseRunOutcome> {
45 let endpoint = SuotarEndpoint::ListByCourse;
46 let mut conn = ctx.pool.acquire().await?;
47 let mut tx = conn.begin().await?;
48 let claimed =
49 claim_stalest_for_listing(&mut tx, endpoint.max_batch_size() as i64, scope.course_id)
50 .await?;
51 tx.commit().await?;
52 let attempted = i32::try_from(claimed.len()).unwrap_or(i32::MAX);
53
54 let mut items = Vec::new();
55 let mut realisations = Vec::new();
56 let mut items_failed = 0;
57 for realisation in claimed {
58 let Some(course_code) = listable_course_code(&realisation) else {
59 warn!(
62 "Course module {} has a Suotar realisation but no course code, so it cannot be listed.",
63 realisation.course_module_id
64 );
65 items_failed += 1;
66 mark_listing_failed(
67 &mut conn,
68 realisation.id,
69 CreditRegistrationErrorCode::MissingUhCourseCode,
70 )
71 .await?;
72 continue;
73 };
74 items.push(ListByCourseRequestItem {
75 request_item_id: listing_request_item_id(realisation.id),
76 course_code,
77 course_unit_realisation_id: Some(realisation.course_unit_realisation_id.clone()),
78 });
79 realisations.push(realisation);
80 }
81 if items.is_empty() {
82 return Ok(PhaseRunOutcome {
83 items_processed: attempted,
84 items_failed,
85 error: None,
86 });
87 }
88 drop(conn);
90
91 let response = ctx
92 .suotar_client
93 .list_enrolments_by_course(
94 SuotarCallContext::new(ctx.worker_name(CreditRegistrationPhase::EnrolmentDiscovery)),
95 items,
96 )
97 .await;
98 let response = match response {
99 Err(error) => return Ok(whole_request_failed(attempted, &error)),
102 Ok(response) => response,
103 };
104
105 let mut conn = ctx.pool.acquire().await?;
106 for realisation in &realisations {
107 let item = response.item(&listing_request_item_id(realisation.id));
108 let listed = match item {
109 Some(item) if item.status == SuotarItemStatus::Ok => Ok(item
110 .result
111 .as_ref()
112 .map(|result| result.people.as_slice())
113 .unwrap_or_default()),
114 Some(item) => {
115 warn!(
116 "Listing realisation {} failed with {}.",
117 realisation.course_unit_realisation_id, item.code
118 );
119 Err(map_code(endpoint, &item.code).unwrap_or(CreditRegistrationErrorCode::Unknown))
120 }
121 None => {
122 warn!(
123 "The study registry did not answer for realisation {}.",
124 realisation.course_unit_realisation_id
125 );
126 Err(CreditRegistrationErrorCode::UnexpectedResponse)
127 }
128 };
129 let people = match listed {
130 Ok(people) => people,
131 Err(error) => {
132 items_failed += 1;
133 mark_listing_failed(&mut conn, realisation.id, error).await?;
134 continue;
135 }
136 };
137 let outcome = reconcile(ctx, &mut conn, realisation, people).await?;
138 record_listing_outcome(&mut conn, realisation.id, &outcome).await?;
139 }
140
141 Ok(PhaseRunOutcome {
142 items_processed: attempted,
143 items_failed,
144 error: every_item_failed_transiently(&response).then(|| {
145 "Every realisation of the batch came back transiently unavailable.".to_string()
146 }),
147 })
148}
149
150async fn reconcile(
152 ctx: &PhaseContext<'_>,
153 conn: &mut PgConnection,
154 realisation: &RealisationToList,
155 people: &[ListedPerson],
156) -> anyhow::Result<RealisationListingOutcome> {
157 let mut outcome = RealisationListingOutcome {
158 listed_person_count: i32::try_from(people.len()).unwrap_or(i32::MAX),
159 ..RealisationListingOutcome::default()
160 };
161 let person_ids: Vec<String> = people
162 .iter()
163 .map(|person| person.person_id.clone())
164 .collect();
165 let linked = verified_student_numbers::get_by_sisu_person_ids(conn, &person_ids).await?;
166 let linked_person_ids: HashSet<&str> = linked
167 .iter()
168 .map(|row| row.sisu_person_id.as_str())
169 .collect();
170
171 let mut fast_track = FastTrackRun::new(ctx, realisation);
172 fast_track
173 .resolve_accounts(
174 conn,
175 people
176 .iter()
177 .filter(|person| !linked_person_ids.contains(person.person_id.as_str())),
178 )
179 .await?;
180 let mut discovered = Vec::new();
181 for person in people {
182 if linked_person_ids.contains(person.person_id.as_str()) {
183 outcome.already_linked_count += 1;
184 continue;
185 }
186 let addresses = listed_person_addresses(person);
187 if addresses.is_empty() {
188 outcome.no_address_count += 1;
190 continue;
191 }
192 if fast_track.try_link(conn, person, &mut outcome).await? {
193 continue;
194 }
195 discovered.push(DiscoveredPerson {
196 sisu_person_id: person.person_id.clone(),
197 student_number: person.student_number.clone(),
198 first_names: Some(person.first_names.clone()),
199 last_name: Some(person.last_name.clone()),
200 course_id: realisation.course_id,
201 addresses,
202 });
203 }
204 if !discovered.is_empty() {
205 for claimed in claim_linking_mails_batch(conn, &discovered).await? {
206 outcome.mailed_count += claimed.claimed;
207 outcome.suppressed_by_dedup_count += claimed.suppressed_by_dedup;
208 outcome.suppressed_by_rate_cap_count += claimed.suppressed_by_rate_cap;
209 }
210 }
211
212 let linked_user_ids: Vec<_> = linked.iter().map(|row| row.user_id).collect();
215 if !linked_user_ids.is_empty() {
216 recheck_no_usable_enrolment_now(conn, realisation.course_id, &linked_user_ids).await?;
217 }
218 Ok(outcome)
219}
220
221struct FastTrackRun<'a> {
224 ctx: &'a PhaseContext<'a>,
225 realisation: &'a RealisationToList,
226 enabled: bool,
227 max_verification_age: chrono::Duration,
228 templates: TemplateCache,
229 accounts: HashMap<String, FastTrackCandidate>,
232}
233
234impl<'a> FastTrackRun<'a> {
235 fn new(ctx: &'a PhaseContext<'a>, realisation: &'a RealisationToList) -> Self {
236 let conf = ctx.suotar_conf;
237 Self {
238 ctx,
239 realisation,
240 enabled: conf.fast_track_email_match_enabled,
241 max_verification_age: chrono::Duration::days(
242 conf.fast_track_max_email_verification_age_days.max(0),
243 ),
244 templates: TemplateCache::default(),
245 accounts: HashMap::new(),
246 }
247 }
248
249 async fn resolve_accounts<'p>(
250 &mut self,
251 conn: &mut PgConnection,
252 people: impl Iterator<Item = &'p ListedPerson>,
253 ) -> anyhow::Result<()> {
254 if !self.enabled {
255 return Ok(());
256 }
257 let wanted: Vec<FastTrackLookup> = people
258 .map(|person| FastTrackLookup {
259 primary_email: person.primary_email.clone(),
260 sisu_person_id: person.person_id.clone(),
261 })
262 .collect();
263 self.accounts = find_fast_track_candidates(conn, &wanted).await?;
264 Ok(())
265 }
266
267 fn decide(
268 &self,
269 candidate: Option<&FastTrackCandidate>,
270 person: &ListedPerson,
271 ) -> FastTrackDecision {
272 decide_fast_track(
273 candidate,
274 RegistryName {
275 first_names: Some(&person.first_names),
276 last_name: Some(&person.last_name),
277 },
278 Utc::now(),
279 self.max_verification_age,
280 )
281 }
282
283 async fn try_link(
286 &mut self,
287 conn: &mut PgConnection,
288 person: &ListedPerson,
289 outcome: &mut RealisationListingOutcome,
290 ) -> anyhow::Result<bool> {
291 if !self.enabled {
292 return Ok(false);
293 }
294 let decision = self.decide(self.accounts.get(&person.person_id), person);
295 if decision != FastTrackDecision::Link {
296 count_decision(outcome, decision);
297 return Ok(false);
298 }
299 let mut tx = conn.begin().await?;
304 let candidate =
307 find_fast_track_candidate(&mut tx, &person.primary_email, &person.person_id).await?;
308 let decision = self.decide(candidate.as_ref(), person);
309 count_decision(outcome, decision);
310 let (FastTrackDecision::Link, Some(candidate)) = (decision, candidate) else {
311 return Ok(false);
312 };
313
314 link_by_email_match(
315 &mut tx,
316 &FastTrackLink {
317 student_number: &person.student_number,
318 sisu_person_id: &person.person_id,
319 first_names: Some(&person.first_names),
320 last_name: Some(&person.last_name),
321 course_id: self.realisation.course_id,
322 },
323 &candidate,
324 )
325 .await?;
326 self.notify(&mut tx, person, &candidate).await?;
327 tx.commit().await?;
328 Ok(true)
329 }
330
331 async fn notify(
335 &mut self,
336 conn: &mut PgConnection,
337 person: &ListedPerson,
338 candidate: &FastTrackCandidate,
339 ) -> anyhow::Result<()> {
340 let language = template_language(&self.realisation.course_language_code);
341 let Some(template_id) = self
342 .templates
343 .id_for(
344 conn,
345 EmailTemplateType::CreditRegistrationStudentNumberLinked,
346 &language,
347 )
348 .await?
349 else {
350 warn!(
351 "No credit_registration_student_number_linked email template in {language}, so an automatic link went unannounced."
352 );
353 return Ok(());
354 };
355 insert_email_delivery_with_placeholders(
356 conn,
357 candidate.user_id,
358 template_id,
359 &json!({
360 "NAME": candidate.first_name.clone().unwrap_or_default(),
361 "STUDENT_NUMBER": person.student_number,
362 "LINK": student_number_settings_url(self.ctx.base_url),
363 }),
364 )
365 .await?;
366 Ok(())
367 }
368}
369
370fn count_decision(outcome: &mut RealisationListingOutcome, decision: FastTrackDecision) {
373 match decision {
374 FastTrackDecision::NoAccountMatch => outcome.fast_track_skipped_no_account_count += 1,
375 FastTrackDecision::UnverifiedAccount => outcome.fast_track_skipped_unverified_count += 1,
376 FastTrackDecision::StaleVerification => {
377 outcome.fast_track_skipped_stale_verification_count += 1
378 }
379 FastTrackDecision::NameMismatch => outcome.fast_track_skipped_name_mismatch_count += 1,
380 FastTrackDecision::AccountHasStudentNumber => {
381 outcome.fast_track_skipped_account_has_number_count += 1
382 }
383 FastTrackDecision::UnlinkedBefore => outcome.fast_track_skipped_unlinked_before_count += 1,
384 FastTrackDecision::Link => outcome.fast_tracked_count += 1,
385 }
386}
387
388fn student_number_settings_url(base_url: &str) -> String {
390 format!(
391 "{}/user-settings/student-number",
392 base_url.trim_end_matches('/')
393 )
394}
395
396pub(super) fn listable_course_code(realisation: &RealisationToList) -> Option<String> {
397 realisation
398 .uh_course_code
399 .clone()
400 .filter(|code| !code.trim().is_empty())
401}
402
403fn whole_request_failed(attempted: i32, error: &UtilError) -> PhaseRunOutcome {
404 PhaseRunOutcome {
405 items_processed: attempted,
406 items_failed: attempted,
407 error: Some(scrub_text(error.message())),
408 }
409}