diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 6be180b..b2ff30d 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -5,11 +5,13 @@ use crate::error::AppError; mod auth_routes; mod courses; +mod rooms; pub fn build(pool: SqlitePool) -> Router { Router::new() .merge(auth_routes::router()) .merge(courses::router()) + .merge(rooms::router()) .with_state(pool) } diff --git a/backend/src/routes/rooms.rs b/backend/src/routes/rooms.rs new file mode 100644 index 0000000..46d5e08 --- /dev/null +++ b/backend/src/routes/rooms.rs @@ -0,0 +1,269 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, post, put}, + Json, Router, +}; +use serde_json::{json, Value}; +use sqlx::SqlitePool; +use std::collections::HashSet; + +use crate::{ + auth::TutorClaims, + error::AppError, + models::{CreateRoom, LayoutElement, Room}, +}; + +fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> { + let valid_types = ["seat", "table", "gap", "door"]; + let mut ids: HashSet<&str> = HashSet::new(); + let mut seat_labels: HashSet<&str> = HashSet::new(); + + for elem in layout { + // id must be non-empty + if elem.id.is_empty() { + return Err(AppError::BadRequest("element id must be non-empty".into())); + } + // all ids must be unique + if !ids.insert(elem.id.as_str()) { + return Err(AppError::BadRequest(format!( + "duplicate element id: {}", + elem.id + ))); + } + // type must be valid + if !valid_types.contains(&elem.kind.as_str()) { + return Err(AppError::BadRequest(format!( + "invalid element type '{}'; must be one of: seat, table, gap, door", + elem.kind + ))); + } + // x, y, width, height must be >= 0.0 + if elem.x < 0.0 || elem.y < 0.0 || elem.width < 0.0 || elem.height < 0.0 { + return Err(AppError::BadRequest( + "x, y, width, height must all be >= 0.0".into(), + )); + } + // seat labels must be unique among seats + if elem.kind == "seat" { + if !seat_labels.insert(elem.label.as_str()) { + return Err(AppError::BadRequest(format!( + "duplicate seat label: {}", + elem.label + ))); + } + } + } + + Ok(()) +} + +async fn list_rooms( + _claims: TutorClaims, + State(pool): State, +) -> Result>, AppError> { + let rows = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms ORDER BY id") + .fetch_all(&pool) + .await?; + let result: Vec = rows + .into_iter() + .map(|r| json!({"id": r.id, "name": r.name})) + .collect(); + Ok(Json(result)) +} + +async fn create_room( + _claims: TutorClaims, + State(pool): State, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + validate_layout(&req.layout)?; + let layout_json = serde_json::to_string(&req.layout) + .map_err(|e| AppError::BadRequest(format!("layout serialization error: {e}")))?; + let id = sqlx::query("INSERT INTO rooms (name, layout_json) VALUES (?, ?)") + .bind(&req.name) + .bind(&layout_json) + .execute(&pool) + .await? + .last_insert_rowid(); + Ok((StatusCode::CREATED, Json(json!({"id": id, "name": req.name})))) +} + +async fn get_room( + _claims: TutorClaims, + State(pool): State, + Path(id): Path, +) -> Result, AppError> { + let row = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?") + .bind(id) + .fetch_optional(&pool) + .await? + .ok_or(AppError::NotFound)?; + let layout: Vec = serde_json::from_str(&row.layout_json).map_err(|e| { + AppError::BadRequest(format!("layout parse error: {e}")) + })?; + Ok(Json(json!({"id": row.id, "name": row.name, "layout": layout}))) +} + +async fn update_room_layout( + _claims: TutorClaims, + State(pool): State, + Path(id): Path, + Json(layout): Json>, +) -> Result, AppError> { + // verify room exists + let _row = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?") + .bind(id) + .fetch_optional(&pool) + .await? + .ok_or(AppError::NotFound)?; + + validate_layout(&layout)?; + let layout_json = serde_json::to_string(&layout) + .map_err(|e| AppError::BadRequest(format!("layout serialization error: {e}")))?; + sqlx::query("UPDATE rooms SET layout_json = ? WHERE id = ?") + .bind(&layout_json) + .bind(id) + .execute(&pool) + .await?; + Ok(Json(json!({"id": id}))) +} + +pub fn router() -> Router { + Router::new() + .route("/api/admin/rooms", get(list_rooms).post(create_room)) + .route("/api/admin/rooms/{id}", get(get_room)) + .route("/api/admin/rooms/{id}/layout", put(update_room_layout)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::{build_test_app, get, post_json}; + use axum::http::StatusCode; + use serde_json::{json, Value}; + + #[sqlx::test(migrations = "./migrations")] + async fn create_room_with_layout(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool).await; + let layout = json!([ + {"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}, + {"id":"s2","label":"A2","x":1.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"} + ]); + let (status, body) = post_json( + app.clone(), + "/api/admin/rooms", + &auth, + json!({"name":"Room 101","layout":layout}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + let (status, body) = get(app, &format!("/api/admin/rooms/{id}"), &auth).await; + assert_eq!(status, StatusCode::OK); + let room = serde_json::from_slice::(&body).unwrap(); + assert_eq!(room["layout"].as_array().unwrap().len(), 2); + } + + #[sqlx::test(migrations = "./migrations")] + async fn layout_validation_rejects_duplicate_ids(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool).await; + let layout = json!([ + {"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}, + {"id":"s1","label":"A2","x":1.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"} + ]); + let (status, _) = post_json( + app, + "/api/admin/rooms", + &auth, + json!({"name":"Bad","layout":layout}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[sqlx::test(migrations = "./migrations")] + async fn layout_validation_rejects_invalid_type(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool).await; + let layout = json!([ + {"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"window"} + ]); + let (status, _) = post_json( + app, + "/api/admin/rooms", + &auth, + json!({"name":"Bad","layout":layout}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[sqlx::test(migrations = "./migrations")] + async fn layout_validation_rejects_duplicate_seat_labels(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool).await; + let layout = json!([ + {"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}, + {"id":"s2","label":"A1","x":1.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"} + ]); + let (status, _) = post_json( + app, + "/api/admin/rooms", + &auth, + json!({"name":"Bad","layout":layout}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[sqlx::test(migrations = "./migrations")] + async fn update_room_layout(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool).await; + // create room + let (_, body) = post_json( + app.clone(), + "/api/admin/rooms", + &auth, + json!({"name":"R1","layout":[ + {"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"} + ]}), + ) + .await; + let id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // update layout via PUT + use axum::http::Request; + use http_body_util::BodyExt; + use tower::ServiceExt; + let new_layout = json!([ + {"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}, + {"id":"s2","label":"A2","x":1.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"} + ]); + let req = Request::builder() + .method("PUT") + .uri(format!("/api/admin/rooms/{id}/layout")) + .header("Content-Type", "application/json") + .header("Authorization", &auth) + .body(axum::body::Body::from(new_layout.to_string())) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // verify new layout has 2 seats + let (status, body) = get(app, &format!("/api/admin/rooms/{id}"), &auth).await; + assert_eq!(status, StatusCode::OK); + let room = serde_json::from_slice::(&body).unwrap(); + assert_eq!(room["layout"].as_array().unwrap().len(), 2); + } + + #[sqlx::test(migrations = "./migrations")] + async fn rooms_requires_auth(pool: sqlx::SqlitePool) { + let (app, _) = build_test_app(pool).await; + let (status, _) = get(app, "/api/admin/rooms", "").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } +}