542 lines
14 KiB
Rust
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))
|
|
}
|