diff --git a/api/migrations/20250101002000_update_persons_schema.sql b/api/migrations/20250101002000_update_persons_schema.sql new file mode 100644 index 0000000..eb94118 --- /dev/null +++ b/api/migrations/20250101002000_update_persons_schema.sql @@ -0,0 +1,23 @@ +BEGIN; + +DROP TABLE IF EXISTS persons; + +CREATE TABLE persons ( + id SERIAL PRIMARY KEY, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + grade INTEGER NOT NULL, + parent_name TEXT NOT NULL, + parent_phone_number TEXT NOT NULL, + checked_in BOOLEAN NOT NULL DEFAULT FALSE, + inside BOOLEAN NOT NULL DEFAULT FALSE, + visitor BOOLEAN NOT NULL DEFAULT FALSE, + sleeping_spot BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX idx_persons_first_name_trgm ON persons USING GIN (first_name gin_trgm_ops); +CREATE INDEX idx_persons_last_name_trgm ON persons USING GIN (last_name gin_trgm_ops); +CREATE INDEX idx_persons_parent_name_trgm ON persons USING GIN (parent_name gin_trgm_ops); +CREATE INDEX idx_persons_parent_phone_number ON persons (parent_phone_number); + +COMMIT; diff --git a/api/src/main.rs b/api/src/main.rs index ef92106..6e82206 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -187,13 +187,25 @@ async fn search_persons( let persons = if let Some(id) = id_value { sqlx::query_as::<_, Person>( r#" - SELECT id, name, age, phone_number, checked_in, inside + SELECT + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot FROM persons - WHERE name ILIKE $1 - OR phone_number ILIKE $1 + WHERE first_name ILIKE $1 + OR last_name ILIKE $1 + OR parent_name ILIKE $1 + OR parent_phone_number ILIKE $1 + OR (first_name || ' ' || last_name) ILIKE $1 OR id = $2 - ORDER BY name - LIMIT 100 + ORDER BY last_name, first_name "#, ) .bind(&like_pattern) @@ -203,12 +215,24 @@ async fn search_persons( } else { sqlx::query_as::<_, Person>( r#" - SELECT id, name, age, phone_number, checked_in, inside + SELECT + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot FROM persons - WHERE name ILIKE $1 - OR phone_number ILIKE $1 - ORDER BY name - LIMIT 100 + WHERE first_name ILIKE $1 + OR last_name ILIKE $1 + OR parent_name ILIKE $1 + OR parent_phone_number ILIKE $1 + OR (first_name || ' ' || last_name) ILIKE $1 + ORDER BY last_name, first_name "#, ) .bind(&like_pattern) @@ -252,7 +276,7 @@ async fn list_checked_in( let id_value = search_term.as_ref().and_then(|s| s.parse::().ok()); let mut qb = QueryBuilder::::new( - "SELECT id, name, age, phone_number, checked_in, inside FROM persons", + "SELECT id, first_name, last_name, grade, parent_name, parent_phone_number, checked_in, inside, visitor, sleeping_spot FROM persons", ); let mut first_condition = true; @@ -278,8 +302,12 @@ async fn list_checked_in( if let Some(like) = like_pattern.as_ref() { append_condition(&mut qb); qb.push("("); - qb.push("name ILIKE ").push_bind(like); - qb.push(" OR phone_number ILIKE ").push_bind(like); + qb.push("first_name ILIKE ").push_bind(like); + qb.push(" OR last_name ILIKE ").push_bind(like); + qb.push(" OR parent_name ILIKE ").push_bind(like); + qb.push(" OR parent_phone_number ILIKE ").push_bind(like); + qb.push(" OR (first_name || ' ' || last_name) ILIKE ") + .push_bind(like); if let Some(id) = id_value { qb.push(" OR id = ").push_bind(id); } @@ -289,12 +317,9 @@ async fn list_checked_in( if let Some(id) = id_value { qb.push(" ORDER BY CASE WHEN id = ") .push_bind(id) - .push(" THEN 0 ELSE 1 END, id, name"); + .push(" THEN 0 ELSE 1 END, id, last_name, first_name"); } else { - qb.push(" ORDER BY id, name"); - } - if like_pattern.is_some() { - qb.push(" LIMIT 1000"); + qb.push(" ORDER BY id, last_name, first_name"); } let persons = qb.build_query_as::().fetch_all(&state.db).await?; @@ -345,53 +370,116 @@ async fn create_person( state: &State, payload: Json, ) -> Result, ApiError> { - let name = payload.name.trim(); - if name.is_empty() { - return Err(ApiError::bad_request("Namn får inte vara tomt.")); + let first_name = payload.first_name.trim(); + if first_name.is_empty() { + return Err(ApiError::bad_request("Förnamn får inte vara tomt.")); } - if payload.age < 0 { - return Err(ApiError::bad_request("Ålder måste vara noll eller högre.")); + let last_name = payload.last_name.trim(); + if last_name.is_empty() { + return Err(ApiError::bad_request("Efternamn får inte vara tomt.")); } - let phone_number = payload.phone_number.trim(); - if phone_number.is_empty() { - return Err(ApiError::bad_request("Telefonnummer krävs.")); + if payload.grade < 0 { + return Err(ApiError::bad_request("Klass måste vara noll eller högre.")); } - let age = payload.age; + let parent_name = payload.parent_name.trim(); + if parent_name.is_empty() { + return Err(ApiError::bad_request("Kontaktperson krävs.")); + } + + let parent_phone_number = payload.parent_phone_number.trim(); + if parent_phone_number.is_empty() { + return Err(ApiError::bad_request( + "Kontaktpersonens telefonnummer krävs.", + )); + } + + let grade = payload.grade; let checked_in = payload.checked_in.unwrap_or(false); let inside = payload.inside.unwrap_or(false); + let visitor = payload.visitor.unwrap_or(false); + let sleeping_spot = payload.sleeping_spot.unwrap_or(false); let person = match payload.id { Some(id) => sqlx::query_as::<_, Person>( r#" - INSERT INTO persons (id, name, age, phone_number, checked_in, inside) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, name, age, phone_number, checked_in, inside + 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, $7, $8, $9, $10) + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot "#, ) .bind(id) - .bind(name) - .bind(age) - .bind(phone_number) + .bind(first_name) + .bind(last_name) + .bind(grade) + .bind(parent_name) + .bind(parent_phone_number) .bind(checked_in) .bind(inside) + .bind(visitor) + .bind(sleeping_spot) .fetch_one(&state.db) .await .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, None => sqlx::query_as::<_, Person>( r#" - INSERT INTO persons (name, age, phone_number, checked_in, inside) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, name, age, phone_number, checked_in, inside + INSERT INTO persons ( + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot "#, ) - .bind(name) - .bind(age) - .bind(phone_number) + .bind(first_name) + .bind(last_name) + .bind(grade) + .bind(parent_name) + .bind(parent_phone_number) .bind(checked_in) .bind(inside) + .bind(visitor) + .bind(sleeping_spot) .fetch_one(&state.db) .await .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, @@ -415,7 +503,17 @@ async fn update_checked_in( SET checked_in = $2, inside = $2 WHERE id = $1 - RETURNING id, name, age, phone_number, checked_in, inside + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot "#, ) .bind(id) @@ -446,7 +544,17 @@ async fn update_inside( SET inside = $2 WHERE id = $1 AND checked_in = TRUE - RETURNING id, name, age, phone_number, checked_in, inside + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot "#, ) .bind(id) @@ -484,7 +592,7 @@ fn map_db_error(err: sqlx::Error, context: &str) -> ApiError { if let Some(code) = db_err.code() { if code == "23505" { return ApiError::bad_request( - "Krock i databasen – kontrollera id eller telefonnummer.", + "Krock i databasen – kontrollera id eller kontaktuppgifter.", ); } } diff --git a/api/src/models.rs b/api/src/models.rs index 4493ac1..dc31b62 100644 --- a/api/src/models.rs +++ b/api/src/models.rs @@ -12,11 +12,15 @@ pub struct User { #[derive(Debug, FromRow)] pub struct Person { pub id: i32, - pub name: String, - pub age: i32, - pub phone_number: String, + pub first_name: String, + pub last_name: String, + pub grade: i32, + pub parent_name: String, + pub parent_phone_number: String, pub checked_in: bool, pub inside: bool, + pub visitor: bool, + pub sleeping_spot: bool, } #[derive(Debug, Deserialize)] @@ -36,25 +40,30 @@ pub struct LoginResponse { #[serde(crate = "rocket::serde")] pub struct PersonResponse { pub id: i32, - pub name: String, - pub age: i32, - pub phone_number: String, + pub first_name: String, + pub last_name: String, + pub grade: i32, + pub parent_name: String, + pub parent_phone_number: String, pub checked_in: bool, pub inside: bool, - pub under_ten: bool, + pub visitor: bool, + pub sleeping_spot: bool, } impl From for PersonResponse { fn from(person: Person) -> Self { - let under_ten = person.age < 10; PersonResponse { id: person.id, - name: person.name, - age: person.age, - phone_number: person.phone_number, + first_name: person.first_name, + last_name: person.last_name, + grade: person.grade, + parent_name: person.parent_name, + parent_phone_number: person.parent_phone_number, checked_in: person.checked_in, inside: person.inside, - under_ten, + visitor: person.visitor, + sleeping_spot: person.sleeping_spot, } } } @@ -74,13 +83,19 @@ pub struct PersonActionResponse { #[derive(Debug, Deserialize)] #[serde(crate = "rocket::serde")] pub struct NewPersonRequest { - pub name: String, - pub age: i32, - pub phone_number: String, + pub first_name: String, + pub last_name: String, + pub grade: i32, + pub parent_name: String, + pub parent_phone_number: String, #[serde(default)] pub id: Option, #[serde(default)] pub checked_in: Option, #[serde(default)] pub inside: Option, + #[serde(default)] + pub visitor: Option, + #[serde(default)] + pub sleeping_spot: Option, } diff --git a/api/src/seed.rs b/api/src/seed.rs index dbce535..f0e71d0 100644 --- a/api/src/seed.rs +++ b/api/src/seed.rs @@ -45,13 +45,30 @@ async fn ensure_person_seed(pool: &PgPool) -> anyhow::Result<()> { for person in persons { sqlx::query( - "INSERT INTO persons (name, age, phone_number, checked_in, inside) VALUES ($1, $2, $3, $4, $5)", + r#" + INSERT INTO persons ( + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, ) - .bind(&person.name) - .bind(person.age) - .bind(&person.phone_number) + .bind(&person.first_name) + .bind(&person.last_name) + .bind(person.grade) + .bind(&person.parent_name) + .bind(&person.parent_phone_number) .bind(person.checked_in) .bind(person.inside) + .bind(person.visitor) + .bind(person.sleeping_spot) .execute(pool) .await .context("Misslyckades att lägga till seed-person")?; @@ -61,20 +78,24 @@ async fn ensure_person_seed(pool: &PgPool) -> anyhow::Result<()> { } struct PersonSeed { - name: String, - age: i32, - phone_number: String, + first_name: String, + last_name: String, + grade: i32, + parent_name: String, + parent_phone_number: String, checked_in: bool, inside: bool, + visitor: bool, + sleeping_spot: bool, } fn generate_people() -> Vec { - let first_names = vec![ + let first_names = [ "Alex", "Bianca", "Cecilia", "David", "Elias", "Fatima", "Gabriel", "Hanna", "Isak", "Johanna", "Karin", "Liam", "Maja", "Nils", "Olivia", ]; - let last_names = vec![ + let last_names = [ "Andersson", "Berg", "Carlsson", @@ -87,20 +108,52 @@ fn generate_people() -> Vec { "Johansson", ]; + let guardian_first_names = [ + "Anna", + "Bertil", + "Charlotte", + "Daniel", + "Emma", + "Fredrik", + "Greta", + "Henrik", + "Ingrid", + "Jakob", + "Klara", + "Lars", + "Maria", + "Niklas", + "Petra", + ]; + + let guardian_last_names = [ + "Lind", "Sandberg", "Forsberg", "Nyström", "Sjöberg", "Viklund", "Ågren", "Öberg", + "Boström", "Engman", + ]; + let mut people = Vec::with_capacity(first_names.len() * last_names.len()); let mut idx: usize = 0; for first in &first_names { for last in &last_names { - let name = format!("{} {}", first, last); - let age = 5 + ((idx * 11) % 60) as i32; - let phone_number = format!("070{:03}{:04}", idx % 1_000, (idx * 37) % 10_000); + let grade = 1 + ((idx * 3) % 9) as i32; // Grades between 1 and 9 + let parent_first = guardian_first_names[idx % guardian_first_names.len()]; + let parent_last = guardian_last_names[idx % guardian_last_names.len()]; + let parent_name = format!("{} {}", parent_first, parent_last); + let parent_phone_number = format!("070{:03}{:04}", idx % 1_000, (idx * 37) % 10_000); + let visitor = idx % 7 == 0; + let sleeping_spot = !visitor && grade >= 3 && idx % 5 == 0; + people.push(PersonSeed { - name, - age, - phone_number, + first_name: (*first).to_string(), + last_name: (*last).to_string(), + grade, + parent_name, + parent_phone_number, checked_in: false, inside: false, + visitor, + sleeping_spot, }); idx += 1; }