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 { 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, #[serde(default)] pub guardian_name: Option, #[serde(default)] pub guardian_phone: Option, #[serde(default)] pub is_visiting: Option, } #[derive(Debug, Deserialize)] struct ExternalImportResponse { pub participants: Vec, } #[rocket::post("/import/external")] pub async fn import_persons_external( _user: AuthUser, state: &rocket::State, ) -> Result, 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 = 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::()) .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::("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?")] pub async fn search_persons( _user: AuthUser, state: &rocket::State, q: &str, ) -> Result, 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::().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?&&")] pub async fn list_checked_in( _user: AuthUser, state: &rocket::State, checked: Option, status: Option<&str>, q: Option<&str>, ) -> Result, 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::() .fetch_all(&state.db) .await?; Ok(Json(PersonsResponse { persons: persons.into_iter().map(PersonResponse::from).collect(), })) } #[rocket::post("//checkin")] pub async fn checkin_person( _user: AuthUser, state: &rocket::State, id: i32, ) -> Result, 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("//checkout")] pub async fn checkout_person( _user: AuthUser, state: &rocket::State, id: i32, ) -> Result, 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("//inside")] pub async fn mark_inside( _user: AuthUser, state: &rocket::State, id: i32, ) -> Result, 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("//outside")] pub async fn mark_outside( _user: AuthUser, state: &rocket::State, id: i32, ) -> Result, 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 = "")] pub async fn create_person( _user: AuthUser, state: &rocket::State, payload: Json, ) -> Result, 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("/", data = "")] pub async fn update_person( _user: AuthUser, state: &rocket::State, id: i32, payload: Json, ) -> Result, 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 = "")] pub async fn import_persons( _user: AuthUser, state: &rocket::State, payload: rocket::data::Data<'_>, ) -> Result, 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 = 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::() { 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::()); 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::("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, 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) -> Result, 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) -> Option { match normalize_phone_number(value) { Ok(result) => result, Err(_) => None, } } fn build_person_from_row(row: &PgRow) -> Result { let id = row .try_get::("id") .map_err(|err| format!("Kunde inte läsa id: {err}"))?; let first_name = row .try_get::("first_name") .map_err(|err| format!("Kunde inte läsa förnamn: {err}"))?; let last_name = row .try_get::("last_name") .map_err(|err| format!("Kunde inte läsa efternamn: {err}"))?; let grade = row .try_get::, _>("grade") .map_err(|err| format!("Kunde inte läsa klass: {err}"))?; let parent_name = row .try_get::, _>("parent_name") .map_err(|err| format!("Kunde inte läsa vårdnadshavare: {err}"))?; let parent_phone_number = row .try_get::, _>("parent_phone_number") .map_err(|err| format!("Kunde inte läsa telefonnummer: {err}"))?; let checked_in = row .try_get::("checked_in") .map_err(|err| format!("Kunde inte läsa incheckningsstatus: {err}"))?; let inside = row .try_get::("inside") .map_err(|err| format!("Kunde inte läsa inne-status: {err}"))?; let visitor = row .try_get::("visitor") .map_err(|err| format!("Kunde inte läsa besökarstatus: {err}"))?; let sleeping_spot = row .try_get::("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, }) }