vbytes-lan-attendence/api/src/routes/persons.rs
2026-02-25 21:16:28 +01:00

1142 lines
32 KiB
Rust

use crate::auth::AuthUser;
use crate::error::ApiError;
use crate::models::{
AppEvent, ImportPersonError, ImportPersonsResponse, NewPersonRequest, Person,
PersonActionResponse, PersonResponse, PersonsResponse, UpdatePersonRequest,
};
use crate::AppState;
use csv::ReaderBuilder;
use rocket::data::ToByteUnit;
use rocket::serde::json::Json;
use rocket::Route;
use serde::Deserialize;
use sqlx::{postgres::PgRow, QueryBuilder, Row};
enum SearchCondition {
Checked(bool),
Inside(bool),
Query(String),
}
pub fn routes() -> Vec<Route> {
rocket::routes![
search_persons,
list_checked_in,
checkin_person,
checkout_person,
mark_inside,
mark_outside,
create_person,
update_person,
import_persons,
import_persons_external
]
}
#[derive(Debug, Deserialize)]
struct ExternalParticipant {
pub id: i32,
pub lan_id: i32,
pub first_name: String,
pub surname: String,
#[serde(default)]
pub grade: Option<String>,
#[serde(default)]
pub guardian_name: Option<String>,
#[serde(default)]
pub guardian_phone: Option<String>,
#[serde(default)]
pub is_visiting: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct ExternalImportResponse {
pub participants: Vec<ExternalParticipant>,
}
#[rocket::post("/import/external")]
pub async fn import_persons_external(
_user: AuthUser,
state: &rocket::State<AppState>,
) -> Result<Json<ImportPersonsResponse>, ApiError> {
let url = state
.external_persons_url
.as_deref()
.ok_or_else(|| ApiError::bad_request("Saknar konfiguration: EXTERNAL_PERSONS_URL"))?;
let mut cmd = tokio::process::Command::new("curl");
cmd.arg("-s")
.arg("-v") // Added verbose for better debugging
.arg("-L")
.arg("--max-time")
.arg("30");
if state.external_danger_accept_invalid_certs {
cmd.arg("-k");
}
if let Some(ca_path) = state.external_ca_cert_file.as_deref() {
cmd.arg("--cacert").arg(ca_path);
}
if let (Some(cert_path), Some(key_path)) = (
state.external_client_cert_file.as_deref(),
state.external_client_key_file.as_deref(),
) {
cmd.arg("--cert").arg(cert_path);
cmd.arg("--key").arg(key_path);
}
if let Some(api_key) = state.external_persons_api_key.as_deref() {
cmd.arg("-H").arg(format!("x-api-key: {}", api_key));
}
cmd.arg(url);
let output = cmd.output().await.map_err(|err| {
eprintln!("Failed to execute curl: {err:?}");
ApiError::internal("Kunde inte starta HTTP-klient (curl).")
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
eprintln!("curl failed with status {}: {}", output.status, stderr);
eprintln!("curl stdout (body if any): {}", stdout);
return Err(ApiError::internal(format!(
"Extern källa svarade med fel (curl status {}). Body: {}",
output.status, stdout
)));
}
let bytes = output.stdout;
let response: ExternalImportResponse = match serde_json::from_slice(&bytes) {
Ok(res) => res,
Err(err) => {
eprintln!("Failed to parse external import JSON: {err:?}");
return Err(ApiError::internal(
"Kunde inte tolka data från extern källa.",
));
}
};
let participants = response.participants;
let mut imported = 0usize;
let mut updated = 0usize;
let mut errors: Vec<ImportPersonError> = Vec::new();
for (index, participant) in participants.into_iter().enumerate() {
let line_number = index + 1;
let first_name = participant.first_name.trim().to_string();
if first_name.is_empty() {
errors.push(ImportPersonError {
line: line_number,
message: "Förnamn saknas.".to_string(),
});
continue;
}
let last_name = participant.surname.trim().to_string();
if last_name.is_empty() {
errors.push(ImportPersonError {
line: line_number,
message: "Efternamn saknas.".to_string(),
});
continue;
}
let grade = participant
.grade
.as_deref()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.map(|v| v.parse::<i32>())
.transpose();
let grade = match grade {
Ok(value) => value,
Err(_) => {
errors.push(ImportPersonError {
line: line_number,
message: "Ogiltigt klassvärde.".to_string(),
});
continue;
}
};
if let Some(value) = grade {
if value < 0 {
errors.push(ImportPersonError {
line: line_number,
message: "Klass måste vara ett tal större än eller lika med 0.".to_string(),
});
continue;
}
}
let parent_name = participant
.guardian_name
.as_deref()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.map(|v| v.to_string());
let parent_phone_number = normalize_phone_number_lenient(
participant
.guardian_phone
.as_deref()
.map(|v| v.trim().to_string()),
);
let visitor = participant.is_visiting.unwrap_or(0) != 0;
let sleeping_spot = false;
let id = if participant.lan_id > 0 {
participant.lan_id
} else {
participant.id
};
let result = sqlx::query(
r#"
INSERT INTO persons (
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
) VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE, $7, $8)
ON CONFLICT (id) DO UPDATE SET
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
grade = EXCLUDED.grade,
parent_name = EXCLUDED.parent_name,
parent_phone_number = EXCLUDED.parent_phone_number,
visitor = EXCLUDED.visitor
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot,
(xmax = 0) AS inserted
"#,
)
.bind(id)
.bind(&first_name)
.bind(&last_name)
.bind(grade)
.bind(parent_name)
.bind(parent_phone_number)
.bind(visitor)
.bind(sleeping_spot)
.fetch_one(&state.db)
.await;
match result {
Ok(row) => {
let person = match build_person_from_row(&row) {
Ok(person) => person,
Err(message) => {
errors.push(ImportPersonError {
line: line_number,
message,
});
continue;
}
};
let inserted = row.try_get::<bool, _>("inserted").unwrap_or(false);
if inserted {
imported += 1;
} else {
updated += 1;
}
let response: PersonResponse = person.into();
let _ = state
.event_sender
.send(AppEvent::PersonUpdated { person: response });
}
Err(err) => {
eprintln!("Failed to upsert person during external import: {err:?}");
errors.push(ImportPersonError {
line: line_number,
message: "Kunde inte spara personen i databasen.".to_string(),
});
}
}
}
Ok(Json(ImportPersonsResponse {
imported,
updated,
failed: errors.len(),
errors,
}))
}
#[rocket::get("/search?<q>")]
pub async fn search_persons(
_user: AuthUser,
state: &rocket::State<AppState>,
q: &str,
) -> Result<Json<PersonsResponse>, ApiError> {
let query = q.trim();
if query.is_empty() {
return Err(ApiError::bad_request("Söktext krävs."));
}
let like_pattern = format!("%{}%", query);
let id_value = query.parse::<i32>().ok();
let persons = if let Some(id) = id_value {
sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE id = $1
UNION
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE
CONCAT(first_name, ' ', last_name) ILIKE $2
OR parent_name ILIKE $2
OR parent_phone_number ILIKE $2
LIMIT 50
"#,
)
.bind(id)
.bind(&like_pattern)
.fetch_all(&state.db)
.await?
} else {
sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE
CONCAT(first_name, ' ', last_name) ILIKE $1
OR parent_name ILIKE $1
OR parent_phone_number ILIKE $1
ORDER BY checked_in DESC, inside DESC, id ASC
LIMIT 50
"#,
)
.bind(&like_pattern)
.fetch_all(&state.db)
.await?
};
let response = PersonsResponse {
persons: persons.into_iter().map(PersonResponse::from).collect(),
};
Ok(Json(response))
}
#[rocket::get("/checked-in?<checked>&<status>&<q>")]
pub async fn list_checked_in(
_user: AuthUser,
state: &rocket::State<AppState>,
checked: Option<bool>,
status: Option<&str>,
q: Option<&str>,
) -> Result<Json<PersonsResponse>, ApiError> {
let mut query_builder = QueryBuilder::new(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
"#,
);
let mut conditions = Vec::new();
if let Some(checked_in) = checked {
conditions.push(SearchCondition::Checked(checked_in));
}
if let Some(status) = status {
match status {
"inside" => conditions.push(SearchCondition::Inside(true)),
"outside" => conditions.push(SearchCondition::Inside(false)),
_ => {}
}
}
if let Some(query) = q.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}) {
conditions.push(SearchCondition::Query(query));
}
let mut first = true;
for condition in &conditions {
if first {
query_builder.push(" WHERE ");
first = false;
} else {
query_builder.push(" AND ");
}
match condition {
SearchCondition::Checked(flag) => {
query_builder.push("checked_in = ");
query_builder.push_bind(*flag);
}
SearchCondition::Inside(flag) => {
query_builder.push("inside = ");
query_builder.push_bind(*flag);
}
SearchCondition::Query(term) => {
let like = format!("%{}%", term);
let starts_with = format!("{}%", term);
let digits: String = term.chars().filter(|ch| ch.is_ascii_digit()).collect();
query_builder.push("(");
query_builder.push("CAST(id AS TEXT) ILIKE ");
query_builder.push_bind(starts_with.clone());
query_builder.push(" OR CONCAT(first_name, ' ', last_name) ILIKE ");
query_builder.push_bind(like.clone());
query_builder.push(" OR parent_name ILIKE ");
query_builder.push_bind(like.clone());
query_builder.push(" OR parent_phone_number ILIKE ");
query_builder.push_bind(like.clone());
if !digits.is_empty() {
let digits_like = format!("%{}%", digits);
query_builder
.push(" OR REGEXP_REPLACE(parent_phone_number, '[^0-9]', '', 'g') ILIKE ");
query_builder.push_bind(digits_like);
}
query_builder.push(")");
}
}
}
query_builder.push(" ORDER BY checked_in DESC, inside DESC, id ASC");
let persons = query_builder
.build_query_as::<Person>()
.fetch_all(&state.db)
.await?;
Ok(Json(PersonsResponse {
persons: persons.into_iter().map(PersonResponse::from).collect(),
}))
}
#[rocket::post("/<id>/checkin")]
pub async fn checkin_person(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET checked_in = true,
inside = true
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/<id>/checkout")]
pub async fn checkout_person(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET checked_in = false, inside = false
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/<id>/inside")]
pub async fn mark_inside(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET inside = true
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/<id>/outside")]
pub async fn mark_outside(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET inside = false
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/", data = "<payload>")]
pub async fn create_person(
_user: AuthUser,
state: &rocket::State<AppState>,
payload: Json<NewPersonRequest>,
) -> Result<Json<PersonActionResponse>, ApiError> {
let data = payload.into_inner();
if data.first_name.trim().is_empty() {
return Err(ApiError::bad_request("Förnamn krävs."));
}
if data.last_name.trim().is_empty() {
return Err(ApiError::bad_request("Efternamn krävs."));
}
let parent_phone_number = match normalize_phone_number(data.parent_phone_number.clone()) {
Ok(value) => value,
Err(message) => return Err(ApiError::bad_request(message)),
};
let person = sqlx::query_as::<_, Person>(
r#"
INSERT INTO persons (
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot,
id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,
CASE WHEN $10 IS NULL THEN nextval('persons_id_seq') ELSE $10 END
)
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(data.first_name.trim())
.bind(data.last_name.trim())
.bind(data.grade)
.bind(data.parent_name.map(|v| v.trim().to_string()))
.bind(parent_phone_number)
.bind(data.checked_in.unwrap_or(false))
.bind(data.inside.unwrap_or(false))
.bind(data.visitor.unwrap_or(false))
.bind(data.sleeping_spot.unwrap_or(false))
.bind(data.id)
.fetch_one(&state.db)
.await?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::put("/<id>", data = "<payload>")]
pub async fn update_person(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
payload: Json<UpdatePersonRequest>,
) -> Result<Json<PersonActionResponse>, ApiError> {
let data = payload.into_inner();
if data.first_name.trim().is_empty() {
return Err(ApiError::bad_request("Förnamn krävs."));
}
if data.last_name.trim().is_empty() {
return Err(ApiError::bad_request("Efternamn krävs."));
}
let parent_phone_number = match normalize_phone_number(data.parent_phone_number.clone()) {
Ok(value) => value,
Err(message) => return Err(ApiError::bad_request(message)),
};
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET
first_name = $2,
last_name = $3,
grade = $4,
parent_name = $5,
parent_phone_number = $6,
checked_in = COALESCE($7, checked_in),
inside = COALESCE($8, inside),
visitor = $9,
sleeping_spot = $10
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.bind(data.first_name.trim())
.bind(data.last_name.trim())
.bind(data.grade)
.bind(data.parent_name.map(|v| v.trim().to_string()))
.bind(parent_phone_number)
.bind(data.checked_in)
.bind(data.inside)
.bind(data.visitor)
.bind(data.sleeping_spot)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/import", data = "<payload>")]
pub async fn import_persons(
_user: AuthUser,
state: &rocket::State<AppState>,
payload: rocket::data::Data<'_>,
) -> Result<Json<ImportPersonsResponse>, ApiError> {
let import_limit = 5.mebibytes();
let csv_data = payload
.open(import_limit)
.into_string()
.await
.map_err(|_| ApiError::bad_request("CSV-filen är för stor."))?
.into_inner();
if csv_data.trim().is_empty() {
return Err(ApiError::bad_request("CSV-filen innehåller inga rader."));
}
let mut reader = ReaderBuilder::new()
.has_headers(false)
.flexible(true)
.from_reader(csv_data.as_bytes());
let mut imported = 0usize;
let mut updated = 0usize;
let mut errors: Vec<ImportPersonError> = Vec::new();
for (index, record_result) in reader.records().enumerate() {
let line_number = index + 1;
let record = match record_result {
Ok(record) => record,
Err(err) => {
errors.push(ImportPersonError {
line: line_number,
message: format!("Kunde inte läsa raden: {err}"),
});
continue;
}
};
let id_value = record
.get(0)
.map(|value| value.trim().to_string())
.unwrap_or_default();
if id_value.is_empty() {
errors.push(ImportPersonError {
line: line_number,
message: "ID saknas.".to_string(),
});
continue;
}
let id = match id_value.parse::<i32>() {
Ok(value) => value,
Err(_) => {
errors.push(ImportPersonError {
line: line_number,
message: format!("Ogiltigt ID-värde: {id_value}"),
});
continue;
}
};
let first_name = record
.get(1)
.map(|value| value.trim().to_string())
.unwrap_or_default();
if first_name.is_empty() {
errors.push(ImportPersonError {
line: line_number,
message: "Förnamn saknas.".to_string(),
});
continue;
}
let last_name = record
.get(2)
.map(|value| value.trim().to_string())
.unwrap_or_default();
if last_name.is_empty() {
errors.push(ImportPersonError {
line: line_number,
message: "Efternamn saknas.".to_string(),
});
continue;
}
let grade = record
.get(3)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|value| value.parse::<i32>());
let grade = match grade.transpose() {
Ok(value) => value,
Err(_) => {
errors.push(ImportPersonError {
line: line_number,
message: "Ogiltigt klassvärde.".to_string(),
});
continue;
}
};
if let Some(value) = grade {
if value < 0 {
errors.push(ImportPersonError {
line: line_number,
message: "Klass måste vara ett tal större än eller lika med 0.".to_string(),
});
continue;
}
}
let parent_name = record
.get(4)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|value| value.to_string());
let parent_phone_number =
normalize_phone_number_lenient(record.get(5).map(|value| value.trim().to_string()));
let visitor = match parse_optional_bool(record.get(6)) {
Ok(value) => value.unwrap_or(false),
Err(message) => {
errors.push(ImportPersonError {
line: line_number,
message,
});
continue;
}
};
let sleeping_spot = match parse_optional_bool(record.get(7)) {
Ok(value) => value.unwrap_or(false),
Err(message) => {
errors.push(ImportPersonError {
line: line_number,
message,
});
continue;
}
};
let result = sqlx::query(
r#"
INSERT INTO persons (
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
) VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE, $7, $8)
ON CONFLICT (id) DO UPDATE SET
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
grade = EXCLUDED.grade,
parent_name = EXCLUDED.parent_name,
parent_phone_number = EXCLUDED.parent_phone_number,
visitor = EXCLUDED.visitor
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot,
(xmax = 0) AS inserted
"#,
)
.bind(id)
.bind(&first_name)
.bind(&last_name)
.bind(grade)
.bind(parent_name.clone())
.bind(parent_phone_number.clone())
.bind(visitor)
.bind(sleeping_spot)
.fetch_one(&state.db)
.await;
match result {
Ok(row) => {
let person = match build_person_from_row(&row) {
Ok(person) => person,
Err(message) => {
errors.push(ImportPersonError {
line: line_number,
message,
});
continue;
}
};
let inserted = row.try_get::<bool, _>("inserted").unwrap_or(false);
if inserted {
imported += 1;
} else {
updated += 1;
}
let response: PersonResponse = person.into();
let _ = state
.event_sender
.send(AppEvent::PersonUpdated { person: response });
}
Err(err) => {
eprintln!("Failed to upsert person during import: {err:?}");
errors.push(ImportPersonError {
line: line_number,
message: "Kunde inte spara personen i databasen.".to_string(),
});
}
}
}
Ok(Json(ImportPersonsResponse {
imported,
updated,
failed: errors.len(),
errors,
}))
}
fn parse_optional_bool(value: Option<&str>) -> Result<Option<bool>, String> {
let raw = match value {
Some(v) => v.trim(),
None => return Ok(None),
};
if raw.is_empty() {
return Ok(Some(false));
}
let normalized = raw.to_lowercase();
match normalized.as_str() {
"1" | "true" | "t" | "ja" | "yes" | "y" => Ok(Some(true)),
"0" | "false" | "f" | "nej" | "no" | "n" => Ok(Some(false)),
other => Err(format!("Ogiltigt booleanvärde: {other}")),
}
}
fn normalize_phone_number(value: Option<String>) -> Result<Option<String>, String> {
let raw = match value {
Some(v) => {
let trimmed = v.trim();
if trimmed.is_empty() {
return Ok(None);
}
trimmed.to_string()
}
None => return Ok(None),
};
let mut digits: String = raw.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return Err("Ange ett telefonnummer.".to_string());
}
if digits.starts_with("0046") {
digits = digits[4..].to_string();
}
if digits.starts_with("46") && digits.len() > 10 {
digits = digits[2..].to_string();
}
if digits.len() == 9 && digits.starts_with('7') {
digits = format!("0{digits}");
}
if digits.len() == 10 && !digits.starts_with('0') && digits.starts_with('7') {
digits = format!("0{}", &digits[1..]);
}
if digits.len() != 10 || !digits.starts_with('0') {
return Err("Telefonnumret måste innehålla tio siffror och börja med 0.".to_string());
}
Ok(Some(digits))
}
fn normalize_phone_number_lenient(value: Option<String>) -> Option<String> {
match normalize_phone_number(value) {
Ok(result) => result,
Err(_) => None,
}
}
fn build_person_from_row(row: &PgRow) -> Result<Person, String> {
let id = row
.try_get::<i32, _>("id")
.map_err(|err| format!("Kunde inte läsa id: {err}"))?;
let first_name = row
.try_get::<String, _>("first_name")
.map_err(|err| format!("Kunde inte läsa förnamn: {err}"))?;
let last_name = row
.try_get::<String, _>("last_name")
.map_err(|err| format!("Kunde inte läsa efternamn: {err}"))?;
let grade = row
.try_get::<Option<i32>, _>("grade")
.map_err(|err| format!("Kunde inte läsa klass: {err}"))?;
let parent_name = row
.try_get::<Option<String>, _>("parent_name")
.map_err(|err| format!("Kunde inte läsa vårdnadshavare: {err}"))?;
let parent_phone_number = row
.try_get::<Option<String>, _>("parent_phone_number")
.map_err(|err| format!("Kunde inte läsa telefonnummer: {err}"))?;
let checked_in = row
.try_get::<bool, _>("checked_in")
.map_err(|err| format!("Kunde inte läsa incheckningsstatus: {err}"))?;
let inside = row
.try_get::<bool, _>("inside")
.map_err(|err| format!("Kunde inte läsa inne-status: {err}"))?;
let visitor = row
.try_get::<bool, _>("visitor")
.map_err(|err| format!("Kunde inte läsa besökarstatus: {err}"))?;
let sleeping_spot = row
.try_get::<bool, _>("sleeping_spot")
.map_err(|err| format!("Kunde inte läsa sovplatsstatus: {err}"))?;
Ok(Person {
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot,
})
}