Files
tutortool/backend/src/routes/sessions.rs

531 lines
16 KiB
Rust

use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{delete, get, patch, post},
Json, Router,
};
use rand::Rng;
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";
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<SqlitePool>,
Query(q): Query<SessionQuery>,
) -> Result<Json<Value>, 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<SqlitePool>,
Json(req): Json<CreateSession>,
) -> Result<(StatusCode, Json<Value>), 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<SqlitePool>,
Json(req): Json<CreateSlot>,
) -> Result<(StatusCode, Json<Value>), 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::BadRequest("room_id does not exist".into()));
}
}
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<SqlitePool>,
Path(slot_id): Path<i64>,
Json(req): Json<UpdateSlotStatus>,
) -> Result<Json<Value>, 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<String>)> = sqlx::query_as(
"SELECT s.course_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<String> = 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<SqlitePool>,
Path(slot_id): Path<i64>,
) -> Result<StatusCode, AppError> {
// 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<SqlitePool> {
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_admin_app, build_test_app, delete, get, patch_json, 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_admin_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::<Value>(&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 = 'admin@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::<Value>(&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::<Value>(&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_admin_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::<Value>(&body).unwrap()["id"]
.as_i64()
.unwrap();
let tutor_id: (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@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::<Value>(&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::<Value>(&body).unwrap()["id"]
.as_i64()
.unwrap();
// Open the slot
let (status, body) = patch_json(
app.clone(),
&format!("/api/admin/slots/{slot_id}/status"),
&auth,
json!({"status": "open"}),
).await;
assert_eq!(status, StatusCode::OK);
let slot = serde_json::from_slice::<Value>(&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_admin_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::<Value>(&body).unwrap()["id"]
.as_i64()
.unwrap();
let tutor_id: (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@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::<Value>(&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_admin_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::<Value>(&body).unwrap()["id"]
.as_i64()
.unwrap();
let tutor_id: (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@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::<Value>(&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::<Value>(&body).unwrap()["id"]
.as_i64()
.unwrap();
let (status, _) = delete(app, &format!("/api/admin/slots/{slot_id}"), &auth).await;
assert_eq!(status, StatusCode::NO_CONTENT);
}
}