use crate::auth::AuthUser; use crate::error::ApiError; use crate::models::{ AppEvent, NewPersonRequest, Person, PersonActionResponse, PersonResponse, PersonsResponse, UpdatePersonRequest, }; use crate::AppState; use rocket::serde::json::Json; use rocket::Route; use sqlx::QueryBuilder; 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 ] } #[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.")); } if let Some(ref phone) = data.parent_phone_number { let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect(); if digits.len() != 10 { return Err(ApiError::bad_request( "Telefonnumret måste innehålla tio siffror.", )); } } 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(data.parent_phone_number.map(|v| v.trim().to_string())) .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.")); } if let Some(ref phone) = data.parent_phone_number { let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect(); if digits.len() != 10 { return Err(ApiError::bad_request( "Telefonnumret måste innehålla tio siffror.", )); } } 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(data.parent_phone_number.map(|v| v.trim().to_string())) .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)) }