feat(attendance): sessions/slots CRUD with atomic code generation
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
538
backend/src/routes/sessions.rs
Normal file
538
backend/src/routes/sessions.rs
Normal file
@@ -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<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::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<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.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_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::<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 = '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::<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_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 = '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::<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
|
||||
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::<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_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 = '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::<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_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 = '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::<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user