use std::collections::HashSet;
use std::fmt;
use std::str::FromStr;
use serde::Serialize;
use uuid::Uuid;
use crate::application::service::phone_blacklist_write_service::{
PhoneBlacklistError, PhoneBlacklistWriteService,
};
use crate::application::service::phone_ports::{PhoneBookPort, PhoneBookSlot, PhoneCandidate};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct E164Number(String);
impl E164Number {
pub(crate) fn from_canonical(canonical: String) -> Self {
debug_assert!(
is_canonical_e164(&canonical),
"E164Number invariant violated: {canonical:?}"
);
Self(canonical)
}
}
impl fmt::Display for E164Number {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for E164Number {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Serialize for E164Number {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
fn is_canonical_e164(s: &str) -> bool {
let mut chars = s.chars();
if chars.next() != Some('+') {
return false;
}
match chars.next() {
Some(c) if c.is_ascii_digit() && c != '0' => {}
_ => return false,
}
let rest: Vec<char> = chars.collect();
(6..=14).contains(&rest.len()) && rest.iter().all(|c| c.is_ascii_digit())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PhoneFormatError {
#[error("phone number is empty")]
Empty { raw: String },
#[error("not a phone number ({detail}): {raw:?}")]
NotANumber { raw: String, detail: String },
#[error("invalid country hint {hint:?}: {raw:?}")]
InvalidCountryHint { raw: String, hint: String },
#[error("national-format number with no country hint: {raw:?}")]
MissingCountryHint { raw: String },
#[error("phone number too long for E.164: {raw:?}")]
TooLong { raw: String },
#[error("phone number too short to be a phone number: {raw:?}")]
TooShort { raw: String },
#[error("number is not valid for country {country}: {raw:?}")]
InvalidForCountry { raw: String, country: String },
}
fn normalize_hint(hint: &str) -> Option<String> {
let up = hint.trim().to_ascii_uppercase();
(up.len() == 2 && up.chars().all(|c| c.is_ascii_alphabetic())).then_some(up)
}
pub fn phone_format(
raw: &str,
country_hint: Option<&str>,
) -> Result<E164Number, PhoneFormatError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(PhoneFormatError::Empty { raw: raw.into() });
}
let international = raw.starts_with('+');
let country = if international {
None
} else {
let hint = country_hint
.ok_or_else(|| PhoneFormatError::MissingCountryHint { raw: raw.into() })?;
let normalized = normalize_hint(hint).ok_or_else(|| PhoneFormatError::InvalidCountryHint {
raw: raw.into(),
hint: hint.into(),
})?;
let id = phonenumber::country::Id::from_str(&normalized)
.map_err(|_| PhoneFormatError::InvalidCountryHint {
raw: raw.into(),
hint: normalized.clone(),
})?;
Some((normalized, id))
};
let parsed = phonenumber::parse(country.as_ref().map(|(_, id)| *id), raw)
.map_err(|e| map_parse_error(e, raw, country.as_ref().map(|(n, _)| n.clone())))?;
let total_digits = parsed.country().code().to_string().len()
+ parsed.national().to_string().len();
if total_digits > 15 {
return Err(PhoneFormatError::TooLong { raw: raw.into() });
}
if total_digits < 7 {
return Err(PhoneFormatError::TooShort { raw: raw.into() });
}
if !phonenumber::is_valid(&parsed) {
return Err(PhoneFormatError::InvalidForCountry {
raw: raw.into(),
country: country
.map(|(n, _)| n)
.unwrap_or_else(|| parsed_country_or_prefix(&parsed)),
});
}
let canonical = phonenumber::format(&parsed).to_string();
if !is_canonical_e164(&canonical) {
return Err(PhoneFormatError::NotANumber {
raw: raw.into(),
detail: format!("formatted to non-canonical {canonical:?}"),
});
}
Ok(E164Number(canonical))
}
fn map_parse_error(
e: phonenumber::ParseError,
raw: &str,
hint_country: Option<String>,
) -> PhoneFormatError {
use phonenumber::ParseError as E;
match e {
E::TooLong => PhoneFormatError::TooLong { raw: raw.into() },
E::TooShortNsn | E::TooShortAfterIdd => PhoneFormatError::TooShort { raw: raw.into() },
E::InvalidCountryCode => match hint_country {
Some(country) => {
PhoneFormatError::InvalidForCountry { raw: raw.into(), country }
}
None => PhoneFormatError::NotANumber {
raw: raw.into(),
detail: "invalid country code".into(),
},
},
other => PhoneFormatError::NotANumber { raw: raw.into(), detail: other.to_string() },
}
}
fn parsed_country_or_prefix(parsed: &phonenumber::PhoneNumber) -> String {
format!("+{}", parsed.country().code())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("no sanitizable phone candidate: {}", .0
.iter()
.map(|(i, e)| format!("[{i}] {e}"))
.collect::<Vec<_>>()
.join("; "))]
pub struct SanitizeFailure(pub Vec<(usize, PhoneFormatError)>);
pub fn sanitize_candidates(candidates: &[PhoneCandidate]) -> Result<E164Number, SanitizeFailure> {
let mut trail = Vec::with_capacity(candidates.len());
for (index, candidate) in candidates.iter().enumerate() {
match phone_format(&candidate.raw, candidate.country_hint.as_deref()) {
Ok(number) => return Ok(number),
Err(e) => trail.push((index, e)),
}
}
Err(SanitizeFailure(trail))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SanitizedPhone {
pub number: E164Number,
pub blacklisted: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum PhoneValidationError {
#[error(transparent)]
Source(#[from] crate::application::service::phone_ports::PhoneSourceError),
#[error(transparent)]
Walk(#[from] SanitizeFailure),
#[error(transparent)]
Blacklist(#[from] PhoneBlacklistError),
}
pub struct PhoneValidationService {
#[allow(dead_code)] pool: sqlx::PgPool,
phone_book: PhoneBookSlot,
blacklist: std::sync::Arc<PhoneBlacklistWriteService>,
}
impl PhoneValidationService {
pub fn new(
pool: sqlx::PgPool,
phone_book: PhoneBookSlot,
blacklist: std::sync::Arc<PhoneBlacklistWriteService>,
) -> Self {
Self { pool, phone_book, blacklist }
}
pub async fn sanitized_for(
&self,
model: &str,
res_id: Uuid,
) -> Result<SanitizedPhone, PhoneValidationError> {
let candidates = self.phone_book.candidates(model, res_id).await?;
let number = sanitize_candidates(&candidates)?;
let blacklisted = self.blacklist.is_listed(&number).await?;
Ok(SanitizedPhone { number, blacklisted })
}
pub async fn check(&self, number: &E164Number) -> Result<bool, PhoneBlacklistError> {
self.blacklist.is_listed(number).await
}
pub async fn check_all(
&self,
numbers: &[E164Number],
) -> Result<HashSet<E164Number>, PhoneBlacklistError> {
self.blacklist.listed_among(numbers).await
}
}