From 797ccacbb23195d1547f1e152e00785599e9b378 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Tue, 28 Apr 2026 03:24:36 +0200 Subject: [PATCH] feat(attendance): sessions/slots CRUD with atomic code generation --- backend/src/routes/mod.rs | 2 + backend/src/routes/sessions.rs | 538 +++++++++++++++++++++++++++++++++ 2 files changed, 540 insertions(+) create mode 100644 backend/src/routes/sessions.rs diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index b2ff30d..3a28374 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -6,12 +6,14 @@ use crate::error::AppError; mod auth_routes; mod courses; mod rooms; +mod sessions; pub fn build(pool: SqlitePool) -> Router { Router::new() .merge(auth_routes::router()) .merge(courses::router()) .merge(rooms::router()) + .merge(sessions::router()) .with_state(pool) } diff --git a/backend/src/routes/sessions.rs b/backend/src/routes/sessions.rs new file mode 100644 index 0000000..f723834 --- /dev/null +++ b/backend/src/routes/sessions.rs @@ -0,0 +1,538 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{delete, get, patch, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::SqlitePool; + +use crate::{ + auth::TutorClaims, + error::AppError, + models::{CreateSession, CreateSlot, Session, Slot}, +}; + +fn generate_code() -> String { + const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + use rand::Rng; + let mut rng = rand::rng(); + (0..8) + .map(|_| CHARS[rng.random_range(0..CHARS.len())] as char) + .collect() +} + +#[derive(Deserialize)] +struct SessionQuery { + course_id: i64, +} + +async fn list_sessions( + claims: TutorClaims, + State(pool): State, + Query(q): Query, +) -> Result, AppError> { + super::verify_tutor_course_access(&pool, claims.sub, q.course_id).await?; + + let sessions = sqlx::query_as::<_, Session>( + "SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY date, week_nr", + ) + .bind(q.course_id) + .fetch_all(&pool) + .await?; + + let mut result = Vec::new(); + for session in sessions { + let slots = sqlx::query_as::<_, Slot>( + "SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code + FROM slots WHERE session_id = ? ORDER BY start_time", + ) + .bind(session.id) + .fetch_all(&pool) + .await?; + result.push(json!({ + "id": session.id, + "course_id": session.course_id, + "week_nr": session.week_nr, + "date": session.date, + "slots": slots, + })); + } + + Ok(Json(json!(result))) +} + +async fn create_session( + claims: TutorClaims, + State(pool): State, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + if req.week_nr <= 0 { + return Err(AppError::BadRequest("week_nr must be > 0".into())); + } + chrono::NaiveDate::parse_from_str(&req.date, "%Y-%m-%d") + .map_err(|_| AppError::BadRequest("date must be YYYY-MM-DD".into()))?; + + super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?; + + let id = sqlx::query( + "INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)", + ) + .bind(req.course_id) + .bind(req.week_nr) + .bind(&req.date) + .execute(&pool) + .await? + .last_insert_rowid(); + + Ok((StatusCode::CREATED, Json(json!({"id": id})))) +} + +async fn create_slot( + claims: TutorClaims, + State(pool): State, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + if req.start_time >= req.end_time { + return Err(AppError::BadRequest( + "start_time must be before end_time".into(), + )); + } + + // Look up the session to get course_id + let session_row: Option<(i64,)> = + sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?") + .bind(req.session_id) + .fetch_optional(&pool) + .await?; + let (course_id,) = session_row.ok_or(AppError::NotFound)?; + + // Verify requesting tutor has access to the course + super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + + // Verify the slot's tutor_id belongs to this course + let member: Option<(i64,)> = sqlx::query_as( + "SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?", + ) + .bind(req.tutor_id) + .bind(course_id) + .fetch_optional(&pool) + .await?; + if member.is_none() { + return Err(AppError::BadRequest( + "tutor_id is not a member of this course".into(), + )); + } + + // Optionally verify room exists + if let Some(room_id) = req.room_id { + let room: Option<(i64,)> = sqlx::query_as("SELECT id FROM rooms WHERE id = ?") + .bind(room_id) + .fetch_optional(&pool) + .await?; + if room.is_none() { + return Err(AppError::NotFound); + } + } + + let id = sqlx::query( + "INSERT INTO slots (session_id, room_id, tutor_id, start_time, end_time, status, code) + VALUES (?, ?, ?, ?, ?, 'closed', NULL)", + ) + .bind(req.session_id) + .bind(req.room_id) + .bind(req.tutor_id) + .bind(&req.start_time) + .bind(&req.end_time) + .execute(&pool) + .await? + .last_insert_rowid(); + + Ok(( + StatusCode::CREATED, + Json(json!({"id": id, "status": "closed"})), + )) +} + +#[derive(Deserialize)] +struct UpdateSlotStatus { + status: String, +} + +async fn update_slot_status( + claims: TutorClaims, + State(pool): State, + Path(slot_id): Path, + Json(req): Json, +) -> Result, AppError> { + if !matches!(req.status.as_str(), "open" | "closed" | "locked") { + return Err(AppError::BadRequest( + "status must be 'open', 'closed', or 'locked'".into(), + )); + } + + // Fetch the slot + its session's course_id for access check + let row: Option<(i64, Option)> = sqlx::query_as( + "SELECT s.id, sl.code + FROM slots sl + JOIN sessions s ON s.id = sl.session_id + WHERE sl.id = ?", + ) + .bind(slot_id) + .fetch_optional(&pool) + .await?; + let (course_id, existing_code) = row.ok_or(AppError::NotFound)?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + + // If transitioning to open and no code yet, generate one (retry on UNIQUE collision) + let code: Option = if req.status == "open" { + if let Some(c) = existing_code { + Some(c) + } else { + let mut generated = None; + for _ in 0..5 { + let candidate = generate_code(); + let conflict: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM slots WHERE code = ?") + .bind(&candidate) + .fetch_optional(&pool) + .await?; + if conflict.is_none() { + generated = Some(candidate); + break; + } + } + Some(generated.ok_or_else(|| { + AppError::BadRequest("could not generate unique code".into()) + })?) + } + } else { + existing_code + }; + + sqlx::query("UPDATE slots SET status = ?, code = ? WHERE id = ?") + .bind(&req.status) + .bind(&code) + .bind(slot_id) + .execute(&pool) + .await?; + + // Fetch the full updated slot + let slot = sqlx::query_as::<_, Slot>( + "SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code + FROM slots WHERE id = ?", + ) + .bind(slot_id) + .fetch_one(&pool) + .await?; + + Ok(Json(json!({ + "id": slot.id, + "session_id": slot.session_id, + "room_id": slot.room_id, + "tutor_id": slot.tutor_id, + "start_time": slot.start_time, + "end_time": slot.end_time, + "status": slot.status, + "code": slot.code, + }))) +} + +async fn delete_slot( + claims: TutorClaims, + State(pool): State, + Path(slot_id): Path, +) -> Result { + // Fetch slot status and course_id in one join + let row: Option<(i64, String)> = sqlx::query_as( + "SELECT s.course_id, sl.status + FROM slots sl + JOIN sessions s ON s.id = sl.session_id + WHERE sl.id = ?", + ) + .bind(slot_id) + .fetch_optional(&pool) + .await?; + let (course_id, status) = row.ok_or(AppError::NotFound)?; + + if status != "closed" { + return Err(AppError::Conflict( + "only closed slots may be deleted".into(), + )); + } + + super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + + sqlx::query("DELETE FROM slots WHERE id = ?") + .bind(slot_id) + .execute(&pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +pub fn router() -> Router { + Router::new() + .route("/api/admin/sessions", get(list_sessions).post(create_session)) + .route("/api/admin/slots", post(create_slot)) + .route("/api/admin/slots/{id}/status", patch(update_slot_status)) + .route("/api/admin/slots/{id}", delete(delete_slot)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::{build_test_app, delete, get, post_json}; + use axum::http::StatusCode; + use serde_json::{json, Value}; + use std::collections::HashSet; + + // Pure unit tests (no DB) + #[test] + fn code_length_and_charset() { + for _ in 0..100 { + let code = generate_code(); + assert_eq!(code.len(), 8); + assert!(code + .chars() + .all(|c| "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".contains(c))); + } + } + + #[test] + fn codes_are_diverse() { + let codes: HashSet<_> = (0..1000).map(|_| generate_code()).collect(); + assert!(codes.len() > 990); + } + + // DB tests + #[sqlx::test(migrations = "./migrations")] + async fn create_and_list_sessions(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + // Create a course + enroll the tutor first + let (status, body) = post_json( + app.clone(), + "/api/admin/courses", + &auth, + json!({"name":"FP","semester":"SS2026"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let course_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + // Enroll tutor in course (needed for verify_tutor_course_access) + let tutor_id: (i64,) = + sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?,?)") + .bind(tutor_id.0) + .bind(course_id) + .execute(&pool) + .await + .unwrap(); + + let (status, body) = post_json( + app.clone(), + "/api/admin/sessions", + &auth, + json!({"course_id": course_id, "week_nr": 1, "date": "2026-04-28"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let session_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + let (status, body) = get( + app, + &format!("/api/admin/sessions?course_id={course_id}"), + &auth, + ) + .await; + assert_eq!(status, StatusCode::OK); + let sessions = serde_json::from_slice::(&body).unwrap(); + assert!(sessions + .as_array() + .unwrap() + .iter() + .any(|s| s["id"] == session_id)); + } + + #[sqlx::test(migrations = "./migrations")] + async fn create_slot_and_open(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + // Setup: course, enroll tutor, session, slot + 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 tutor_id: (i64,) = + sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?,?)") + .bind(tutor_id.0) + .bind(course_id) + .execute(&pool) + .await + .unwrap(); + + let (_, body) = post_json( + app.clone(), + "/api/admin/sessions", + &auth, + json!({"course_id": course_id, "week_nr": 1, "date": "2026-04-28"}), + ) + .await; + let session_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + let (status, body) = post_json( + app.clone(), + "/api/admin/slots", + &auth, + json!({"session_id": session_id, "tutor_id": tutor_id.0, "start_time": "09:00", "end_time": "10:00"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let slot_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // Open the slot + use axum::http::Request; + use http_body_util::BodyExt; + use tower::ServiceExt; + let req = Request::builder() + .method("PATCH") + .uri(format!("/api/admin/slots/{slot_id}/status")) + .header("Content-Type", "application/json") + .header("Authorization", &auth) + .body(axum::body::Body::from( + json!({"status": "open"}).to_string(), + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let slot = serde_json::from_slice::(&body).unwrap(); + assert_eq!(slot["status"], "open"); + let code = slot["code"].as_str().unwrap(); + assert_eq!(code.len(), 8); + } + + #[sqlx::test(migrations = "./migrations")] + async fn slot_start_time_must_be_before_end_time(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + 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 tutor_id: (i64,) = + sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?,?)") + .bind(tutor_id.0) + .bind(course_id) + .execute(&pool) + .await + .unwrap(); + let (_, body) = post_json( + app.clone(), + "/api/admin/sessions", + &auth, + json!({"course_id": course_id, "week_nr": 1, "date": "2026-04-28"}), + ) + .await; + let session_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // end_time <= start_time should fail + let (status, _) = post_json( + app, + "/api/admin/slots", + &auth, + json!({"session_id": session_id, "tutor_id": tutor_id.0, "start_time": "10:00", "end_time": "09:00"}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[sqlx::test(migrations = "./migrations")] + async fn sessions_requires_auth(pool: sqlx::SqlitePool) { + let (app, _) = build_test_app(pool).await; + let (status, _) = get(app, "/api/admin/sessions?course_id=1", "").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + + #[sqlx::test(migrations = "./migrations")] + async fn delete_closed_slot(pool: sqlx::SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + 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 tutor_id: (i64,) = + sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?,?)") + .bind(tutor_id.0) + .bind(course_id) + .execute(&pool) + .await + .unwrap(); + let (_, body) = post_json( + app.clone(), + "/api/admin/sessions", + &auth, + json!({"course_id": course_id, "week_nr": 1, "date": "2026-04-28"}), + ) + .await; + let session_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + let (_, body) = post_json( + app.clone(), + "/api/admin/slots", + &auth, + json!({"session_id": session_id, "tutor_id": tutor_id.0, "start_time": "09:00", "end_time": "10:00"}), + ) + .await; + let slot_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + let (status, _) = delete(app, &format!("/api/admin/slots/{slot_id}"), &auth).await; + assert_eq!(status, StatusCode::NO_CONTENT); + } +}