headless_lms_server/domain/credit_registration/
linking_candidates.rs1use headless_lms_models::library::credit_registration::study_registry::RosterPerson;
5use secrecy::ExposeSecret;
6use utoipa::ToSchema;
7
8use crate::prelude::*;
9
10#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, ToSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum LinkingCandidateSimilarity {
14 Email,
16 EmailUsername,
18 LastName,
19 FirstName,
21}
22
23impl LinkingCandidateSimilarity {
24 fn weight(self) -> u32 {
25 match self {
26 Self::Email => 8,
27 Self::EmailUsername => 4,
28 Self::LastName => 2,
29 Self::FirstName => 1,
30 }
31 }
32}
33
34pub struct AccountIdentity<'a> {
36 pub first_name: Option<&'a str>,
37 pub last_name: Option<&'a str>,
38 pub email: Option<&'a str>,
39}
40
41pub fn similarities(
43 account: &AccountIdentity<'_>,
44 person: &RosterPerson,
45) -> Vec<LinkingCandidateSimilarity> {
46 let mut found = Vec::new();
47 let addresses: Vec<String> = [&person.primary_email, &person.secondary_email]
48 .into_iter()
49 .flatten()
50 .map(|address| normalized(address.expose_secret()))
51 .filter(|address| !address.is_empty())
52 .collect();
53 if let Some(email) = account.email.map(normalized) {
54 if addresses.contains(&email) {
55 found.push(LinkingCandidateSimilarity::Email);
56 } else if let Some((username, _)) = email.split_once('@')
57 && addresses
58 .iter()
59 .any(|address| address.split_once('@').map(|(name, _)| name) == Some(username))
60 {
61 found.push(LinkingCandidateSimilarity::EmailUsername);
62 }
63 }
64 let last_name = person
65 .last_name
66 .as_ref()
67 .map(|name| normalized(name.expose_secret()));
68 if account
69 .last_name
70 .map(normalized)
71 .is_some_and(|name| !name.is_empty() && Some(&name) == last_name.as_ref())
72 {
73 found.push(LinkingCandidateSimilarity::LastName);
74 }
75 let first_names = person
76 .first_names
77 .as_ref()
78 .map(|names| normalized(names.expose_secret()))
79 .unwrap_or_default();
80 if account
81 .first_name
82 .map(normalized)
83 .is_some_and(|account_names| {
84 account_names
85 .split_whitespace()
86 .any(|name| first_names.split_whitespace().any(|listed| listed == name))
87 })
88 {
89 found.push(LinkingCandidateSimilarity::FirstName);
90 }
91 found
92}
93
94pub fn similarity_score(found: &[LinkingCandidateSimilarity]) -> u32 {
96 found.iter().map(|similarity| similarity.weight()).sum()
97}
98
99fn normalized(value: &str) -> String {
100 value.trim().to_lowercase()
101}