diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 14f862c..72b14e7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.95.0" [dependencies] -axum = { version = "0.8", features = ["macros"] } +axum = { version = "0.8", features = ["macros", "multipart"] } tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] } serde = { version = "1", features = ["derive"] } diff --git a/backend/src/routes/courses.rs b/backend/src/routes/courses.rs new file mode 100644 index 0000000..1521c19 --- /dev/null +++ b/backend/src/routes/courses.rs @@ -0,0 +1,265 @@ +use axum::{ + extract::{Multipart, Path, State}, + http::StatusCode, + routing::{delete, get, post}, + Json, Router, +}; +use serde_json::{json, Value}; +use sqlx::SqlitePool; + +use crate::{ + auth::TutorClaims, + error::AppError, + models::{Course, CreateCourse, CreateStudent, Student}, +}; + +async fn list_courses( + _claims: TutorClaims, + State(pool): State, +) -> Result>, AppError> { + let courses = sqlx::query_as::<_, Course>("SELECT id, name, semester FROM courses ORDER BY id") + .fetch_all(&pool) + .await?; + Ok(Json(courses)) +} + +async fn create_course( + _claims: TutorClaims, + State(pool): State, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + let id = sqlx::query("INSERT INTO courses (name, semester) VALUES (?, ?)") + .bind(&req.name) + .bind(&req.semester) + .execute(&pool) + .await? + .last_insert_rowid(); + Ok((StatusCode::CREATED, Json(json!({"id": id, "name": req.name, "semester": req.semester})))) +} + +async fn list_students( + _claims: TutorClaims, + State(pool): State, + Path(course_id): Path, +) -> Result>, AppError> { + let students = sqlx::query_as::<_, Student>( + "SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY id", + ) + .bind(course_id) + .fetch_all(&pool) + .await?; + Ok(Json(students)) +} + +async fn add_student( + _claims: TutorClaims, + State(pool): State, + Path(course_id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + let id = sqlx::query("INSERT INTO students (course_id, name) VALUES (?, ?)") + .bind(course_id) + .bind(&req.name) + .execute(&pool) + .await? + .last_insert_rowid(); + Ok(( + StatusCode::CREATED, + Json(json!({"id": id, "name": req.name, "course_id": course_id})), + )) +} + +async fn import_students( + _claims: TutorClaims, + State(pool): State, + Path(course_id): Path, + mut multipart: Multipart, +) -> Result<(StatusCode, Json), AppError> { + let mut count = 0i64; + + while let Some(field) = multipart.next_field().await.map_err(|e| { + AppError::BadRequest(format!("multipart error: {e}")) + })? { + let text = field.text().await.map_err(|e| { + AppError::BadRequest(format!("field read error: {e}")) + })?; + + let mut lines = text.lines(); + // Skip header row + lines.next(); + + for line in lines { + let name = line.trim(); + if name.is_empty() { + continue; + } + sqlx::query("INSERT INTO students (course_id, name) VALUES (?, ?)") + .bind(course_id) + .bind(name) + .execute(&pool) + .await?; + count += 1; + } + } + + Ok((StatusCode::CREATED, Json(json!({"imported": count})))) +} + +async fn delete_student( + _claims: TutorClaims, + State(pool): State, + Path(student_id): Path, +) -> Result { + let result = sqlx::query("DELETE FROM students WHERE id = ?") + .bind(student_id) + .execute(&pool) + .await?; + if result.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(StatusCode::NO_CONTENT) +} + +pub fn router() -> Router { + Router::new() + .route("/api/admin/courses", get(list_courses).post(create_course)) + .route( + "/api/admin/courses/{id}/students", + get(list_students).post(add_student), + ) + .route("/api/admin/courses/{id}/students/import", post(import_students)) + .route("/api/admin/students/{id}", delete(delete_student)) +} + +#[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}; + use sqlx::SqlitePool; + + #[sqlx::test(migrations = "./migrations")] + async fn create_and_list_courses(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + let (status, body) = post_json( + app.clone(), + "/api/admin/courses", + &auth, + json!({"name":"FP","semester":"SS2026"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + let (status, body) = get(app, "/api/admin/courses", &auth).await; + assert_eq!(status, StatusCode::OK); + assert!(serde_json::from_slice::(&body) + .unwrap() + .as_array() + .unwrap() + .iter() + .any(|c| c["id"] == id)); + } + + #[sqlx::test(migrations = "./migrations")] + async fn create_and_list_students(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + // Create course + let (_, body) = post_json( + app.clone(), + "/api/admin/courses", + &auth, + json!({"name":"FP","semester":"SS2026"}), + ) + .await; + let course_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // Add student + let path = format!("/api/admin/courses/{course_id}/students"); + let (status, body) = + post_json(app.clone(), &path, &auth, json!({"name":"Alice"})).await; + assert_eq!(status, StatusCode::CREATED); + let student_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // List students + let (status, body) = get(app, &path, &auth).await; + assert_eq!(status, StatusCode::OK); + assert!(serde_json::from_slice::(&body) + .unwrap() + .as_array() + .unwrap() + .iter() + .any(|s| s["id"] == student_id)); + } + + #[sqlx::test(migrations = "./migrations")] + async fn create_course_requires_auth(pool: SqlitePool) { + let (app, _) = build_test_app(pool.clone()).await; + let (status, _) = post_json( + app, + "/api/admin/courses", + "", + json!({"name":"FP","semester":"SS2026"}), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + + #[sqlx::test(migrations = "./migrations")] + async fn delete_student_returns_204(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + + // Create course and student + let (_, body) = post_json( + app.clone(), + "/api/admin/courses", + &auth, + json!({"name":"FP","semester":"SS2026"}), + ) + .await; + let course_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + let path = format!("/api/admin/courses/{course_id}/students"); + let (_, body) = post_json(app.clone(), &path, &auth, json!({"name":"Bob"})).await; + let student_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // Delete student + use tower::ServiceExt; + use axum::http::Request; + let req = Request::builder() + .method("DELETE") + .uri(format!("/api/admin/students/{student_id}")) + .header("Authorization", &auth) + .body(axum::body::Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[sqlx::test(migrations = "./migrations")] + async fn delete_student_not_found(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + + use tower::ServiceExt; + use axum::http::Request; + let req = Request::builder() + .method("DELETE") + .uri("/api/admin/students/9999") + .header("Authorization", &auth) + .body(axum::body::Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 10e0bf1..ad5da9d 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -2,9 +2,11 @@ use axum::Router; use sqlx::SqlitePool; mod auth_routes; +mod courses; pub fn build(pool: SqlitePool) -> Router { Router::new() .merge(auth_routes::router()) + .merge(courses::router()) .with_state(pool) }