diff --git a/.env b/.env index 39c0d65..e362c32 100644 --- a/.env +++ b/.env @@ -1,8 +1,8 @@ POSTGRES_PASSWORD=postgrespass123 JWT_SECRET=supersecretjwtkey ADMIN_USERNAME=admin -ADMIN_PASSWORD=AdminPass!234 +ADMIN_PASSWORD=admin JWT_COOKIE_SECURE=false ENABLE_HTTPS_REDIRECT=false WEB_PORT=3000 -CSRF_ALLOWED_ORIGINS=http://192.168.68.61:3000 +CSRF_ALLOWED_ORIGINS=http://192.168.1.201:3000 diff --git a/api/migrations/20250101001000_create_persons.sql b/api/migrations/20250101001000_create_persons.sql index bc0b691..60f3411 100644 --- a/api/migrations/20250101001000_create_persons.sql +++ b/api/migrations/20250101001000_create_persons.sql @@ -1,11 +1,17 @@ CREATE TABLE IF NOT EXISTS persons ( id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - age INTEGER NOT NULL, - phone_number TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + grade INTEGER, + parent_name TEXT, + parent_phone_number TEXT, checked_in BOOLEAN NOT NULL DEFAULT FALSE, - inside 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 IF NOT EXISTS idx_persons_name_trgm ON persons USING GIN (name gin_trgm_ops); -CREATE INDEX IF NOT EXISTS idx_persons_phone_number ON persons (phone_number); +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); diff --git a/api/migrations/20250101002000_update_persons_schema.sql b/api/migrations/20250101002000_update_persons_schema.sql deleted file mode 100644 index eb94118..0000000 --- a/api/migrations/20250101002000_update_persons_schema.sql +++ /dev/null @@ -1,23 +0,0 @@ -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 501bcbf..a2f19cb 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -371,39 +371,48 @@ async fn create_person( state: &State, payload: Json, ) -> Result, ApiError> { - let first_name = payload.first_name.trim(); + let NewPersonRequest { + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + id, + checked_in, + inside, + visitor, + sleeping_spot, + } = payload.into_inner(); + + let first_name = first_name.trim().to_string(); if first_name.is_empty() { return Err(ApiError::bad_request("Förnamn får inte vara tomt.")); } - let last_name = payload.last_name.trim(); + let last_name = last_name.trim().to_string(); if last_name.is_empty() { return Err(ApiError::bad_request("Efternamn får inte vara tomt.")); } - if payload.grade < 0 { + if grade.map(|value| value < 0).unwrap_or(false) { return Err(ApiError::bad_request("Klass måste vara noll eller högre.")); } - let parent_name = payload.parent_name.trim(); - if parent_name.is_empty() { - return Err(ApiError::bad_request("Kontaktperson krävs.")); - } + let parent_name = normalize_optional_string(&parent_name); + let parent_phone_number = normalize_optional_string(&parent_phone_number); - let parent_phone_number = payload.parent_phone_number.trim(); - if parent_phone_number.is_empty() { + let checked_in = checked_in.unwrap_or(false); + let inside = inside.unwrap_or(false); + let visitor = visitor.unwrap_or(false); + let sleeping_spot = sleeping_spot.unwrap_or(false); + + if (checked_in || inside) && !fields_are_complete(grade, &parent_name, &parent_phone_number) { return Err(ApiError::bad_request( - "Kontaktpersonens telefonnummer krävs.", + "Kontaktperson, telefon och klass krävs innan incheckning.", )); } - let grade = payload.grade; - let checked_in = payload.checked_in.unwrap_or(false); - let inside = payload.inside.unwrap_or(false); - let visitor = payload.visitor; - let sleeping_spot = payload.sleeping_spot; - - let person = match payload.id { + let person = match id { Some(id) => sqlx::query_as::<_, Person>( r#" INSERT INTO persons ( @@ -433,11 +442,11 @@ async fn create_person( "#, ) .bind(id) - .bind(first_name) - .bind(last_name) + .bind(&first_name) + .bind(&last_name) .bind(grade) - .bind(parent_name) - .bind(parent_phone_number) + .bind(parent_name.as_deref()) + .bind(parent_phone_number.as_deref()) .bind(checked_in) .bind(inside) .bind(visitor) @@ -472,11 +481,11 @@ async fn create_person( sleeping_spot "#, ) - .bind(first_name) - .bind(last_name) + .bind(&first_name) + .bind(&last_name) .bind(grade) - .bind(parent_name) - .bind(parent_phone_number) + .bind(parent_name.as_deref()) + .bind(parent_phone_number.as_deref()) .bind(checked_in) .bind(inside) .bind(visitor) @@ -500,34 +509,38 @@ async fn update_person( id: i32, payload: Json, ) -> Result, ApiError> { - let first_name = payload.first_name.trim(); + let UpdatePersonRequest { + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot, + } = payload.into_inner(); + + let first_name = first_name.trim().to_string(); if first_name.is_empty() { return Err(ApiError::bad_request("Förnamn får inte vara tomt.")); } - let last_name = payload.last_name.trim(); + let last_name = last_name.trim().to_string(); if last_name.is_empty() { return Err(ApiError::bad_request("Efternamn får inte vara tomt.")); } - if payload.grade < 0 { + if grade.map(|value| value < 0).unwrap_or(false) { return Err(ApiError::bad_request("Klass måste vara noll eller högre.")); } - let parent_name = payload.parent_name.trim(); - if parent_name.is_empty() { - return Err(ApiError::bad_request("Vårdnadshavare krävs.")); - } + let parent_name_present = parent_name.is_some(); + let parent_phone_present = parent_phone_number.is_some(); + let grade_present = grade.is_some(); - let parent_phone_number = payload.parent_phone_number.trim(); - if parent_phone_number.is_empty() { - return Err(ApiError::bad_request("Vårdnadshavarens telefon krävs.")); - } - - let checked_in = payload.checked_in; - let inside = payload.inside; - let visitor = payload.visitor; - let sleeping_spot = payload.sleeping_spot; + let parent_name = normalize_optional_string(&parent_name); + let parent_phone_number = normalize_optional_string(&parent_phone_number); let (final_checked_in, final_inside) = match (checked_in, inside) { (Some(false), _) => (Some(false), Some(false)), @@ -537,6 +550,57 @@ async fn update_person( (None, None) => (None, None), }; + let existing = 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 + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await?; + + let existing = match existing { + Some(person) => person, + None => return Err(ApiError::not_found("Personen hittades inte.")), + }; + + let parent_name = if parent_name_present { + parent_name + } else { + existing.parent_name.clone() + }; + + let parent_phone_number = if parent_phone_present { + parent_phone_number + } else { + existing.parent_phone_number.clone() + }; + + let grade = if grade_present { grade } else { existing.grade }; + + let desired_checked_in = final_checked_in.unwrap_or(existing.checked_in); + let desired_inside = final_inside.unwrap_or(existing.inside); + + if (desired_checked_in || desired_inside) + && !fields_are_complete(grade, &parent_name, &parent_phone_number) + { + return Err(ApiError::bad_request( + "Kontaktperson, telefon och klass krävs innan incheckning.", + )); + } + let person = sqlx::query_as::<_, Person>( r#" UPDATE persons @@ -564,11 +628,11 @@ async fn update_person( "#, ) .bind(id) - .bind(first_name) - .bind(last_name) - .bind(payload.grade) - .bind(parent_name) - .bind(parent_phone_number) + .bind(&first_name) + .bind(&last_name) + .bind(grade) + .bind(parent_name.as_deref()) + .bind(parent_phone_number.as_deref()) .bind(final_checked_in) .bind(final_inside) .bind(visitor) @@ -593,6 +657,38 @@ async fn update_checked_in( id: i32, value: bool, ) -> Result, ApiError> { + let existing = 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 + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await?; + + let existing = match existing { + Some(person) => person, + None => return Err(ApiError::not_found("Personen hittades inte.")), + }; + + if value && !person_is_complete(&existing) { + return Err(ApiError::bad_request( + "Kontaktperson, telefon och klass krävs innan incheckning.", + )); + } + let person = sqlx::query_as::<_, Person>( r#" UPDATE persons @@ -703,3 +799,45 @@ fn map_db_error(err: sqlx::Error, context: &str) -> ApiError { fn broadcast_person_update(state: &State, response: &PersonActionResponse) { let _ = state.event_sender.send(response.clone()); } + +fn normalize_optional_string(value: &Option) -> Option { + value.as_ref().and_then(|text| { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn fields_are_complete( + grade: Option, + parent_name: &Option, + parent_phone_number: &Option, +) -> bool { + grade.is_some() + && parent_name + .as_ref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + && phone_is_valid(parent_phone_number) +} + +fn person_is_complete(person: &Person) -> bool { + fields_are_complete( + person.grade, + &person.parent_name, + &person.parent_phone_number, + ) +} + +fn phone_is_valid(phone: &Option) -> bool { + phone + .as_ref() + .map(|value| { + let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect(); + digits.len() == 10 + }) + .unwrap_or(false) +} diff --git a/api/src/models.rs b/api/src/models.rs index 2995d62..5eb1f55 100644 --- a/api/src/models.rs +++ b/api/src/models.rs @@ -14,9 +14,9 @@ pub struct Person { pub id: i32, pub first_name: String, pub last_name: String, - pub grade: i32, - pub parent_name: String, - pub parent_phone_number: String, + pub grade: Option, + pub parent_name: Option, + pub parent_phone_number: Option, pub checked_in: bool, pub inside: bool, pub visitor: bool, @@ -42,9 +42,9 @@ pub struct PersonResponse { pub id: i32, pub first_name: String, pub last_name: String, - pub grade: i32, - pub parent_name: String, - pub parent_phone_number: String, + pub grade: Option, + pub parent_name: Option, + pub parent_phone_number: Option, pub checked_in: bool, pub inside: bool, pub visitor: bool, @@ -85,9 +85,12 @@ pub struct PersonActionResponse { pub struct NewPersonRequest { pub first_name: String, pub last_name: String, - pub grade: i32, - pub parent_name: String, - pub parent_phone_number: String, + #[serde(default)] + pub grade: Option, + #[serde(default)] + pub parent_name: Option, + #[serde(default)] + pub parent_phone_number: Option, #[serde(default)] pub id: Option, #[serde(default)] @@ -105,9 +108,12 @@ pub struct NewPersonRequest { pub struct UpdatePersonRequest { pub first_name: String, pub last_name: String, - pub grade: i32, - pub parent_name: String, - pub parent_phone_number: String, + #[serde(default)] + pub grade: Option, + #[serde(default)] + pub parent_name: Option, + #[serde(default)] + pub parent_phone_number: Option, #[serde(default)] pub checked_in: Option, #[serde(default)] diff --git a/web/src/lib/client/person-utils.ts b/web/src/lib/client/person-utils.ts new file mode 100644 index 0000000..a1236ba --- /dev/null +++ b/web/src/lib/client/person-utils.ts @@ -0,0 +1,36 @@ +import type { Person } from '$lib/types'; + +export function hasValue(value: string | null | undefined): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export function isPhoneValid(phone: string | null | undefined): boolean { + if (!hasValue(phone)) return false; + const normalized = phone.trim(); + const digits = normalized.replace(/[^0-9]/g, ''); + return digits.length === 10; +} + +export function personIsComplete(person: Person): boolean { + return ( + person.grade != null && + hasValue(person.parent_name) && + isPhoneValid(person.parent_phone_number) + ); +} + +export function isLowerGrade(person: Person): boolean { + return person.grade != null && person.grade <= 3; +} + +export function gradeLabel(person: Person): string { + return person.grade != null ? String(person.grade) : '–'; +} + +export function guardianLabel(person: Person): string { + const name = hasValue(person.parent_name) ? person.parent_name!.trim() : '–'; + const phone = hasValue(person.parent_phone_number) + ? person.parent_phone_number!.trim() + : '–'; + return `${name} (${phone})`; +} diff --git a/web/src/lib/components/edit-person-modal.svelte b/web/src/lib/components/edit-person-modal.svelte index 4048197..0e47ff1 100644 --- a/web/src/lib/components/edit-person-modal.svelte +++ b/web/src/lib/components/edit-person-modal.svelte @@ -1,6 +1,7 @@