diff --git a/backend/src/routes/checkin.rs b/backend/src/routes/checkin.rs new file mode 100644 index 0000000..6bbba36 --- /dev/null +++ b/backend/src/routes/checkin.rs @@ -0,0 +1,647 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde_json::{json, Value}; +use sqlx::SqlitePool; + +use crate::{ + error::AppError, + models::{Attendance, LayoutElement, Room, Slot, Student}, +}; + +/// Parse a single cookie value from a raw `Cookie` header string. +fn parse_cookie(cookie_header: &str, key: &str) -> Option { + for pair in cookie_header.split(';') { + let pair = pair.trim(); + if let Some(rest) = pair.strip_prefix(key) { + if rest.starts_with('=') { + return Some(rest[1..].to_string()); + } + } + } + None +} + +/// URL-decode a percent-encoded string (minimal: only %22 → `"` needed for our cookie). +fn url_decode_minimal(s: &str) -> String { + s.replace("%22", "\"") +} + +async fn get_checkin_info( + State(pool): State, + Path(code): Path, + headers: HeaderMap, +) -> Result, AppError> { + // Look up slot by code + let slot = sqlx::query_as::<_, Slot>( + "SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code + FROM slots WHERE code = ?", + ) + .bind(&code) + .fetch_optional(&pool) + .await? + .ok_or(AppError::NotFound)?; + + // Closed slots are inaccessible + if slot.status == "closed" { + return Err(AppError::NotFound); + } + + // Load layout if room is set + let layout: Option> = if let Some(room_id) = slot.room_id { + let room = sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?") + .bind(room_id) + .fetch_optional(&pool) + .await?; + if let Some(r) = room { + let elements: Vec = serde_json::from_str(&r.layout_json) + .unwrap_or_default(); + Some(elements) + } else { + None + } + } else { + None + }; + + // Load attendances for this slot + let attendances = sqlx::query_as::<_, Attendance>( + "SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id = ?", + ) + .bind(slot.id) + .fetch_all(&pool) + .await?; + + // Parse identity cookie to determine which attendance is "mine" + let cookie_str = headers + .get("cookie") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let my_student_id: Option = parse_cookie(cookie_str, "attendance_identity") + .and_then(|raw| { + let decoded = url_decode_minimal(&raw); + serde_json::from_str::(&decoded).ok() + }) + .and_then(|v| { + // Only valid if the code matches + if v["code"].as_str() == Some(&code) { + v["student_id"].as_i64() + } else { + None + } + }); + + let attendance_json: Vec = attendances + .iter() + .map(|a| { + json!({ + "seat_id": a.seat_id, + "student_id": a.student_id, + "is_mine": my_student_id == Some(a.student_id), + }) + }) + .collect(); + + Ok(Json(json!({ + "slot": { + "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, + }, + "layout": layout, + "attendances": attendance_json, + }))) +} + +async fn get_checkin_students( + State(pool): State, + Path(code): Path, +) -> Result, AppError> { + // Look up slot by code + let slot = sqlx::query_as::<_, Slot>( + "SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code + FROM slots WHERE code = ?", + ) + .bind(&code) + .fetch_optional(&pool) + .await? + .ok_or(AppError::NotFound)?; + + if slot.status == "closed" { + return Err(AppError::NotFound); + } + + // Get course_id from the session + let (course_id,): (i64,) = + sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?") + .bind(slot.session_id) + .fetch_one(&pool) + .await?; + + // Return only students enrolled in that course + let students = sqlx::query_as::<_, Student>( + "SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name", + ) + .bind(course_id) + .fetch_all(&pool) + .await?; + + Ok(Json(json!(students))) +} + +async fn post_checkin( + State(pool): State, + headers: HeaderMap, + Json(req): Json, +) -> Result { + // Look up slot by code + let slot = sqlx::query_as::<_, Slot>( + "SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code + FROM slots WHERE code = ?", + ) + .bind(&req.code) + .fetch_optional(&pool) + .await? + .ok_or(AppError::NotFound)?; + + if slot.status == "closed" { + return Err(AppError::NotFound); + } + + if slot.status != "open" { + return Err(AppError::Conflict("check-in not available".into())); + } + + // If room_id is set, seat_id is required + if slot.room_id.is_some() && req.seat_id.is_none() { + return Err(AppError::BadRequest("seat required".into())); + } + + // Validate seat_id against room layout + if let Some(ref seat_id) = req.seat_id { + if let Some(room_id) = slot.room_id { + let room = sqlx::query_as::<_, Room>( + "SELECT id, name, layout_json FROM rooms WHERE id = ?", + ) + .bind(room_id) + .fetch_optional(&pool) + .await?; + + if let Some(r) = room { + let elements: Vec = serde_json::from_str(&r.layout_json) + .unwrap_or_default(); + let valid = elements + .iter() + .any(|e| &e.id == seat_id && e.kind == "seat"); + if !valid { + return Err(AppError::BadRequest("invalid seat".into())); + } + } + } + } + + // Cookie identity check + let cookie_str = headers + .get("cookie") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if let Some(raw) = parse_cookie(cookie_str, "attendance_identity") { + let decoded = url_decode_minimal(&raw); + if let Ok(identity) = serde_json::from_str::(&decoded) { + if identity["code"].as_str() == Some(&req.code) { + // Same slot — verify student_id matches + if let Some(cookie_student_id) = identity["student_id"].as_i64() { + if cookie_student_id != req.student_id { + return Err(AppError::Conflict("identity mismatch".into())); + } + } + } + } + } + + // Transaction: delete old attendance for (slot_id, student_id), then insert new + let mut tx = pool.begin().await?; + + sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?") + .bind(slot.id) + .bind(req.student_id) + .execute(&mut *tx) + .await?; + + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let insert_result = sqlx::query( + "INSERT INTO attendances (slot_id, student_id, seat_id, checked_in_at) VALUES (?, ?, ?, ?)", + ) + .bind(slot.id) + .bind(req.student_id) + .bind(&req.seat_id) + .bind(&now) + .execute(&mut *tx) + .await; + + match insert_result { + Ok(_) => {} + Err(sqlx::Error::Database(e)) if e.message().contains("UNIQUE") => { + return Err(AppError::Conflict("seat taken".into())); + } + Err(e) => return Err(AppError::Db(e)), + } + + tx.commit().await?; + + // Build set-cookie header + let identity_json = serde_json::to_string(&json!({ + "code": req.code, + "student_id": req.student_id, + })) + .unwrap() + .replace('"', "%22"); + + let cookie_val = format!( + "attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/", + identity_json + ); + + let mut response = Json(json!({"ok": true})).into_response(); + response.headers_mut().insert( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&cookie_val).unwrap(), + ); + Ok(response) +} + +pub fn router() -> Router { + Router::new() + .route("/api/checkin/{code}", get(get_checkin_info)) + .route("/api/checkin/{code}/students", get(get_checkin_students)) + .route("/api/checkin", post(post_checkin)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::{build_test_app, get, patch_json, post_json}; + use axum::http::StatusCode; + use serde_json::{json, Value}; + + /// Seeds a complete open slot with a room containing two seats (s1, s2). + /// Returns (app, auth, code, slot_id, course_id, tutor_id). + async fn seed_open_slot_with_room( + pool: &sqlx::SqlitePool, + ) -> (axum::Router, String, String, i64, i64, i64) { + let (app, auth) = build_test_app(pool.clone()).await; + + // 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(); + + // Tutor enrollment + 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) + .bind(course_id) + .execute(pool) + .await + .unwrap(); + + // Room with 2 seats + let layout = json!([ + {"id": "s1", "label": "S1", "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0, "type": "seat"}, + {"id": "s2", "label": "S2", "x": 1.0, "y": 0.0, "width": 1.0, "height": 1.0, "type": "seat"} + ]); + let (_, body) = post_json( + app.clone(), + "/api/admin/rooms", + &auth, + json!({"name": "Room A", "layout": layout}), + ) + .await; + let room_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // Session + 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(); + + // Slot + let (_, body) = post_json( + app.clone(), + "/api/admin/slots", + &auth, + json!({ + "session_id": session_id, + "room_id": room_id, + "tutor_id": tutor_id, + "start_time": "09:00", + "end_time": "10:00" + }), + ) + .await; + let slot_id = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // Open the slot + let (_, body) = patch_json( + app.clone(), + &format!("/api/admin/slots/{slot_id}/status"), + &auth, + json!({"status": "open"}), + ) + .await; + let code = serde_json::from_slice::(&body).unwrap()["code"] + .as_str() + .unwrap() + .to_string(); + + (app, auth, code, slot_id, course_id, tutor_id) + } + + /// Insert a student in a course, return student_id. + async fn insert_student(pool: &sqlx::SqlitePool, course_id: i64, name: &str) -> i64 { + sqlx::query("INSERT INTO students (course_id, name) VALUES (?, ?)") + .bind(course_id) + .bind(name) + .execute(pool) + .await + .unwrap() + .last_insert_rowid() + } + + #[sqlx::test(migrations = "./migrations")] + async fn cannot_use_closed_slot_code(pool: sqlx::SqlitePool) { + let (app, auth, code, slot_id, _course_id, _tutor_id) = + seed_open_slot_with_room(&pool).await; + + // Close the slot + let (status, _) = patch_json( + app.clone(), + &format!("/api/admin/slots/{slot_id}/status"), + &auth, + json!({"status": "closed"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // GET /api/checkin/{code} should now 404 + let (status, _) = get(app, &format!("/api/checkin/{code}"), "").await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[sqlx::test(migrations = "./migrations")] + async fn locked_slot_still_accessible_read_only(pool: sqlx::SqlitePool) { + let (app, auth, code, slot_id, _course_id, _tutor_id) = + seed_open_slot_with_room(&pool).await; + + // Lock the slot + let (status, _) = patch_json( + app.clone(), + &format!("/api/admin/slots/{slot_id}/status"), + &auth, + json!({"status": "locked"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // GET /api/checkin/{code} should still return 200 + let (status, _) = get(app, &format!("/api/checkin/{code}"), "").await; + assert_eq!(status, StatusCode::OK); + } + + #[sqlx::test(migrations = "./migrations")] + async fn checkin_selects_seat(pool: sqlx::SqlitePool) { + let (app, _auth, code, slot_id, course_id, _tutor_id) = + seed_open_slot_with_room(&pool).await; + let student_id = insert_student(&pool, course_id, "Alice").await; + + let (status, _) = post_json( + app, + "/api/checkin", + "", + json!({"code": code, "student_id": student_id, "seat_id": "s1"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Verify attendance row exists + let row: Option<(i64, Option)> = sqlx::query_as( + "SELECT student_id, seat_id FROM attendances WHERE slot_id = ? AND student_id = ?", + ) + .bind(slot_id) + .bind(student_id) + .fetch_optional(&pool) + .await + .unwrap(); + assert!(row.is_some()); + assert_eq!(row.unwrap().1.as_deref(), Some("s1")); + } + + #[sqlx::test(migrations = "./migrations")] + async fn second_student_cant_take_same_seat(pool: sqlx::SqlitePool) { + let (app, _auth, code, _slot_id, course_id, _tutor_id) = + seed_open_slot_with_room(&pool).await; + let student_a = insert_student(&pool, course_id, "Alice").await; + let student_b = insert_student(&pool, course_id, "Bob").await; + + // Student A takes s1 + let (status, _) = post_json( + app.clone(), + "/api/checkin", + "", + json!({"code": code, "student_id": student_a, "seat_id": "s1"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Student B tries s1 → 409 + let (status, _) = post_json( + app, + "/api/checkin", + "", + json!({"code": code, "student_id": student_b, "seat_id": "s1"}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + } + + #[sqlx::test(migrations = "./migrations")] + async fn seat_change_frees_old_seat(pool: sqlx::SqlitePool) { + let (app, _auth, code, slot_id, course_id, _tutor_id) = + seed_open_slot_with_room(&pool).await; + let student_id = insert_student(&pool, course_id, "Alice").await; + + // Take s1 + let (status, _) = post_json( + app.clone(), + "/api/checkin", + "", + json!({"code": code, "student_id": student_id, "seat_id": "s1"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Change to s2 (same student, same code — cookie check bypassed since no cookie set in test) + let (status, _) = post_json( + app, + "/api/checkin", + "", + json!({"code": code, "student_id": student_id, "seat_id": "s2"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // s1 must be freed (no row for student+slot+seat_id=s1) + let s1_row: Option<(i64,)> = sqlx::query_as( + "SELECT id FROM attendances WHERE slot_id = ? AND student_id = ? AND seat_id = 's1'", + ) + .bind(slot_id) + .bind(student_id) + .fetch_optional(&pool) + .await + .unwrap(); + assert!(s1_row.is_none(), "s1 should be freed"); + + // s2 must be taken + let s2_row: Option<(i64,)> = sqlx::query_as( + "SELECT id FROM attendances WHERE slot_id = ? AND student_id = ? AND seat_id = 's2'", + ) + .bind(slot_id) + .bind(student_id) + .fetch_optional(&pool) + .await + .unwrap(); + assert!(s2_row.is_some(), "s2 should be taken"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn checkin_on_locked_slot_rejected(pool: sqlx::SqlitePool) { + let (app, auth, code, slot_id, course_id, _tutor_id) = + seed_open_slot_with_room(&pool).await; + let student_id = insert_student(&pool, course_id, "Alice").await; + + // Lock the slot + let (status, _) = patch_json( + app.clone(), + &format!("/api/admin/slots/{slot_id}/status"), + &auth, + json!({"status": "locked"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // POST checkin → 409 + let (status, body) = post_json( + app, + "/api/checkin", + "", + json!({"code": code, "student_id": student_id, "seat_id": "s1"}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "check-in not available"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn seat_required_when_room_set(pool: sqlx::SqlitePool) { + let (app, _auth, code, _slot_id, course_id, _tutor_id) = + seed_open_slot_with_room(&pool).await; + let student_id = insert_student(&pool, course_id, "Alice").await; + + // No seat_id → 400 + let (status, body) = post_json( + app, + "/api/checkin", + "", + json!({"code": code, "student_id": student_id}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"].as_str().unwrap().contains("seat required")); + } + + #[sqlx::test(migrations = "./migrations")] + async fn invalid_seat_id_rejected(pool: sqlx::SqlitePool) { + let (app, _auth, code, _slot_id, course_id, _tutor_id) = + seed_open_slot_with_room(&pool).await; + let student_id = insert_student(&pool, course_id, "Alice").await; + + // seat_id "s99" doesn't exist in layout → 400 + let (status, body) = post_json( + app, + "/api/checkin", + "", + json!({"code": code, "student_id": student_id, "seat_id": "s99"}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"].as_str().unwrap().contains("invalid seat")); + } + + #[sqlx::test(migrations = "./migrations")] + async fn students_from_other_course_not_in_dropdown(pool: sqlx::SqlitePool) { + let (app, auth, code, _slot_id, course_id_a, _tutor_id) = + seed_open_slot_with_room(&pool).await; + + // Create a second course (no tutor needed for this test) + let (_, body) = post_json( + app.clone(), + "/api/admin/courses", + &auth, + json!({"name": "Other", "semester": "SS2026"}), + ) + .await; + let course_id_b = serde_json::from_slice::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + let student_a = insert_student(&pool, course_id_a, "Alice").await; + let student_b = insert_student(&pool, course_id_b, "Bob").await; + + let (status, body) = get(app, &format!("/api/checkin/{code}/students"), "").await; + assert_eq!(status, StatusCode::OK); + + let students: Value = serde_json::from_slice(&body).unwrap(); + let ids: Vec = students + .as_array() + .unwrap() + .iter() + .map(|s| s["id"].as_i64().unwrap()) + .collect(); + + assert!(ids.contains(&student_a), "course A student should be present"); + assert!(!ids.contains(&student_b), "course B student must not appear"); + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 3a28374..6b42f3b 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -4,6 +4,7 @@ use sqlx::SqlitePool; use crate::error::AppError; mod auth_routes; +mod checkin; mod courses; mod rooms; mod sessions; @@ -11,6 +12,7 @@ mod sessions; pub fn build(pool: SqlitePool) -> Router { Router::new() .merge(auth_routes::router()) + .merge(checkin::router()) .merge(courses::router()) .merge(rooms::router()) .merge(sessions::router())