531 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|