feat(attendance): student check-in API with FCFS seat locking

This commit is contained in:
2026-04-28 03:43:56 +02:00
parent e75a2bccb8
commit 3629cc3769
2 changed files with 649 additions and 0 deletions

View File

@@ -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<String> {
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<SqlitePool>,
Path(code): Path<String>,
headers: HeaderMap,
) -> Result<Json<Value>, 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<Vec<LayoutElement>> = 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<LayoutElement> = 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<i64> = parse_cookie(cookie_str, "attendance_identity")
.and_then(|raw| {
let decoded = url_decode_minimal(&raw);
serde_json::from_str::<Value>(&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<Value> = 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<SqlitePool>,
Path(code): Path<String>,
) -> Result<Json<Value>, 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<SqlitePool>,
headers: HeaderMap,
Json(req): Json<crate::models::CheckinRequest>,
) -> Result<Response, 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(&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<LayoutElement> = 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::<Value>(&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<SqlitePool> {
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::<Value>(&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::<Value>(&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::<Value>(&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::<Value>(&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::<Value>(&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<String>)> = 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::<Value>(&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<i64> = 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");
}
}

View File

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