Skip to main content

headless_lms_server/domain/credit_registration/
linking_candidates.rs

1//! How much a person on a course's enrolment list resembles a student's account: the order an admin
2//! sees the people a student waiting for a student number may be. A hint for a guess, never a match.
3
4use headless_lms_models::library::credit_registration::study_registry::RosterPerson;
5use secrecy::ExposeSecret;
6use utoipa::ToSchema;
7
8use crate::prelude::*;
9
10/// Something a listed person shares with the student's account, strongest first.
11#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, ToSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum LinkingCandidateSimilarity {
14    /// The account's address is one the study registry holds for them.
15    Email,
16    /// Only the part before the `@` is the same.
17    EmailUsername,
18    LastName,
19    /// One of their first names.
20    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
34/// The student's account as the similarities compare it.
35pub 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
41/// What `person` shares with `account`, strongest first.
42pub 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
94/// How strongly `found` suggests the same person; only for ordering.
95pub 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}