vbytes-lan-attendence/api/src/routes/persons.rs

542 lines
14 KiB
Rust

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<Route> {
rocket::routes![
search_persons,
list_checked_in,
checkin_person,
checkout_person,
mark_inside,
mark_outside,
create_person,
update_person
]
}
#[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."));
}
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("/<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."));
}
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))
}