1142 lines
32 KiB
Rust
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,
|
|
})
|
|
}
|