feat(attendance): sessions/slots CRUD with atomic code generation

This commit is contained in:
2026-04-28 03:24:36 +02:00
parent fcf2787bcc
commit 797ccacbb2
2 changed files with 540 additions and 0 deletions

View File

@@ -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)
}

View 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);
}
}