headless_lms_credit_registration/use_cases/resolve_enrolments/persons/
mod.rs1mod decide;
10
11use std::collections::HashMap;
12
13use headless_lms_models::credit_registrations::claim_due_for_person_lookup;
14use headless_lms_models::secret::DbSecret;
15use headless_lms_models::{study_registry_student_number_conflicts, verified_student_numbers};
16use headless_lms_utils::prelude::Utc;
17use secrecy::ExposeSecret;
18use sqlx::PgConnection;
19use uuid::Uuid;
20
21use super::{claim_for_lookup, hold_in_flight, keep_lookups_in_flight};
22use crate::error::CreditRegistrationResult;
23use crate::registry::{
24 ExchangeAudit, FoundPerson, PersonAnswer, PersonLookup, PersonReading, StudentNumber,
25};
26use crate::use_cases::batch_flow::{BatchFlowContext, Prepared, RegistryBatchFlow};
27use crate::workflow::{Applied, Claimed, RefusalPolicy, write_decision};
28
29use decide::{PersonFill, refused_person_decision, unanswered_person_decision};
30
31pub(super) struct ResolvePersonIds;
32
33pub(super) struct LinkMissingPerson {
35 link_id: Uuid,
36}
37
38impl RegistryBatchFlow for ResolvePersonIds {
39 type Extra = LinkMissingPerson;
40 type Request = PersonLookup;
41
42 const ALL_UNAVAILABLE_ERROR: &'static str = "Every person lookup came back unavailable.";
43 const REFUSAL: RefusalPolicy<LinkMissingPerson> = RefusalPolicy::RequestLevel;
44
45 async fn claim(
48 ctx: &BatchFlowContext<'_>,
49 conn: &mut PgConnection,
50 limit: usize,
51 ) -> CreditRegistrationResult<Prepared<LinkMissingPerson, PersonLookup>> {
52 let claimed =
53 claim_due_for_person_lookup(conn, ctx.scope, i64::try_from(limit).unwrap_or(i64::MAX))
54 .await?;
55 let user_ids: Vec<Uuid> = claimed.iter().map(|row| row.user_id).collect();
56 let links: HashMap<Uuid, _> = verified_student_numbers::get_by_user_ids(conn, &user_ids)
57 .await?
58 .into_iter()
59 .filter(|link| link.sisu_person_id.is_none())
60 .map(|link| (link.user_id, link))
61 .collect();
62
63 let mut prepared = Prepared::new();
64 for row in claimed {
65 let Some(link) = links.get(&row.user_id) else {
66 continue;
67 };
68 let person_lookup = PersonLookup {
69 student_number: StudentNumber::new(link.student_number.clone()),
70 };
71 let row = Claimed {
72 claim: claim_for_lookup(row),
73 extra: LinkMissingPerson { link_id: link.id },
74 };
75 prepared.send(row, person_lookup);
76 }
77 hold_in_flight(
78 conn,
79 prepared.sendable().iter().map(|entry| &entry.row.claim),
80 )
81 .await?;
82 Ok(prepared)
83 }
84
85 async fn apply_answer(
86 conn: &mut PgConnection,
87 row: &Claimed<LinkMissingPerson>,
88 answer: Option<&PersonAnswer>,
89 audit: &ExchangeAudit,
90 ) -> CreditRegistrationResult<Applied> {
91 let claim = &row.claim;
92 let facts = claim.facts(Utc::now());
93 let decision = match answer.map(|answer| &answer.reading) {
94 None => unanswered_person_decision(claim.registration().state, &facts),
95 Some(PersonReading::Refused { code }) => refused_person_decision(*code, &facts),
96 Some(PersonReading::Found(person)) => fill_person_id(conn, &row.extra, person)
99 .await?
100 .decision(claim),
101 };
102 let row_error = answer.and_then(|answer| answer.error_message.as_deref());
103 write_decision(conn, claim, decision.with_row_error(row_error), audit).await
104 }
105
106 async fn keep_in_flight(
107 conn: &mut PgConnection,
108 rows: &[&Claimed<LinkMissingPerson>],
109 ) -> CreditRegistrationResult<()> {
110 keep_lookups_in_flight(conn, rows.iter().map(|row| &row.claim)).await
111 }
112}
113
114async fn fill_person_id(
117 conn: &mut PgConnection,
118 link: &LinkMissingPerson,
119 person: &FoundPerson,
120) -> CreditRegistrationResult<PersonFill> {
121 let sisu_person_id = DbSecret::from(person.person_id.clone());
122 let first_names = person.first_names.clone().map(DbSecret::from);
123 let last_name = person.last_name.clone().map(DbSecret::from);
124 if verified_student_numbers::fill_sisu_person_id(
125 conn,
126 link.link_id,
127 &sisu_person_id,
128 first_names.as_ref(),
129 last_name.as_ref(),
130 )
131 .await?
132 {
133 return Ok(PersonFill::Filled);
134 }
135 let blocking =
136 verified_student_numbers::get_by_sisu_person_id(conn, sisu_person_id.expose_secret())
137 .await?
138 .filter(|blocking| blocking.id != link.link_id);
139 let Some(blocking) = blocking else {
140 return Ok(PersonFill::LinkChanged);
141 };
142 study_registry_student_number_conflicts::record_person_conflict(
143 conn,
144 link.link_id,
145 blocking.id,
146 )
147 .await?;
148 Ok(PersonFill::HeldByAnotherLink)
149}