From 943463fff4197fdb3754436e4f65636aaaa6ad66 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Tue, 28 Apr 2026 05:11:33 +0200 Subject: [PATCH] feat(backend): complete attendance, notes, and export APIs --- backend/Cargo.lock | 27 +++ backend/src/main.rs | 12 +- backend/src/routes/attendance.rs | 218 +++++++++++++++++++++ backend/src/routes/export.rs | 313 +++++++++++++++++++++++++++++++ backend/src/routes/mod.rs | 6 + backend/src/routes/notes.rs | 142 ++++++++++++++ 6 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/attendance.rs create mode 100644 backend/src/routes/export.rs create mode 100644 backend/src/routes/notes.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2fdacb2..df391a0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -95,6 +95,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -482,6 +483,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1170,6 +1180,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/backend/src/main.rs b/backend/src/main.rs index f0545f7..c68b660 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -9,6 +9,7 @@ mod test_helpers; use axum::Router; use tracing_subscriber::EnvFilter; +use tower_http::services::{ServeDir, ServeFile}; #[tokio::main] async fn main() { @@ -17,7 +18,16 @@ async fn main() { .init(); let pool = db::init().await.expect("db init failed"); - let app = routes::build(pool); + + let static_dir = std::env::var("STATIC_DIR") + .unwrap_or_else(|_| "../frontend/build".into()); + + let app = routes::build(pool) + .fallback_service( + ServeDir::new(&static_dir) + .fallback(ServeFile::new(format!("{static_dir}/index.html"))) + ); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await .expect("failed to bind :3000"); tracing::info!("listening on :3000"); diff --git a/backend/src/routes/attendance.rs b/backend/src/routes/attendance.rs new file mode 100644 index 0000000..b8560fe --- /dev/null +++ b/backend/src/routes/attendance.rs @@ -0,0 +1,218 @@ +use axum::{ + extract::{Path, State, Query}, + routing::{get, post, delete}, + Json, Router, +}; +use serde::Deserialize; +use sqlx::SqlitePool; +use crate::{auth::TutorClaims, error::AppError, models::ManualAttendance}; + +#[derive(Deserialize)] +pub struct SessionQuery { + pub session_id: i64, +} + +#[derive(Deserialize)] +pub struct StudentQuery { + pub student_id: i64, +} + +async fn create_attendance( + State(pool): State, + claims: TutorClaims, + Path(slot_id): Path, + Json(req): Json, +) -> Result<(), AppError> { + // Verify tutor access to the course via slot -> session -> course + let course_id: (i64,) = sqlx::query_as( + "SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?" + ) + .bind(slot_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO attendances (slot_id, student_id, seat_id, checked_in_at) VALUES (?, ?, NULL, ?)" + ) + .bind(slot_id) + .bind(req.student_id) + .bind(now) + .execute(&pool) + .await?; + + Ok(()) +} + +async fn delete_attendance( + State(pool): State, + claims: TutorClaims, + Path((slot_id, student_id)): Path<(i64, i64)>, +) -> Result<(), AppError> { + let course_id: (i64,) = sqlx::query_as( + "SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?" + ) + .bind(slot_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?") + .bind(slot_id) + .bind(student_id) + .execute(&pool) + .await?; + + Ok(()) +} + +async fn get_session_attendance( + State(pool): State, + claims: TutorClaims, + Path(session_id): Path, +) -> Result, AppError> { + let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?") + .bind(session_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + // Get all students for the course + let students: Vec = sqlx::query_as( + "SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name" + ) + .bind(course_id.0) + .fetch_all(&pool) + .await?; + + // Get all slots for the session + let slots: Vec = sqlx::query_as( + "SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code FROM slots WHERE session_id = ?" + ) + .bind(session_id) + .fetch_all(&pool) + .await?; + + // Get all attendances for these slots + let attendances: Vec = sqlx::query_as( + "SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)" + ) + .bind(session_id) + .fetch_all(&pool) + .await?; + + let res = serde_json::json!({ + "students": students, + "slots": slots, + "attendances": attendances, + }); + + Ok(Json(res)) +} + +async fn get_student_attendance( + State(pool): State, + claims: TutorClaims, + Path(student_id): Path, +) -> Result, AppError> { + let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM students WHERE id = ?") + .bind(student_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + let attendances: Vec = sqlx::query( + r#" + SELECT a.id, a.slot_id, a.checked_in_at, s.week_nr, sl.start_time + FROM attendances a + JOIN slots sl ON a.slot_id = sl.id + JOIN sessions s ON sl.session_id = s.id + WHERE a.student_id = ? + ORDER BY s.week_nr DESC, sl.start_time DESC + "# + ) + .bind(student_id) + .fetch_all(&pool) + .await? + .into_iter() + .map(|row| { + use sqlx::Row; + serde_json::json!({ + "id": row.get::("id"), + "slot_id": row.get::("slot_id"), + "checked_in_at": row.get::("checked_in_at"), + "week_nr": row.get::("week_nr"), + "start_time": row.get::("start_time"), + }) + }) + .collect(); + + Ok(Json(serde_json::json!(attendances))) +} + +pub fn router() -> Router { + Router::new() + .route("/api/admin/slots/{id}/attendance", post(create_attendance)) + .route("/api/admin/slots/{slot_id}/attendance/{student_id}", delete(delete_attendance)) + .route("/api/admin/sessions/{id}/attendance", get(get_session_attendance)) + .route("/api/admin/students/{id}/attendance", get(get_student_attendance)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::{build_test_app, post_json, get}; + use axum::http::StatusCode; + use serde_json::{json, Value}; + + async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) { + let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'") + .fetch_one(pool).await.unwrap(); + let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id") + .fetch_one(pool).await.unwrap(); + sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)") + .bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap(); + let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id") + .bind(course_id.0).fetch_one(pool).await.unwrap(); + let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id") + .bind(course_id.0).fetch_one(pool).await.unwrap(); + let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id") + .bind(session_id.0).bind(tutor.0).fetch_one(pool).await.unwrap(); + (slot_id.0, student_id.0, session_id.0) + } + + #[sqlx::test(migrations = "./migrations")] + async fn manual_attendance_entry(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + let (slot_id, student_id, _) = seed_data(&pool).await; + + let (status, _) = post_json(app.clone(), &format!("/api/admin/slots/{slot_id}/attendance"), &auth, json!({"student_id": student_id})).await; + assert_eq!(status, StatusCode::OK); + + let (status, body) = get(app, &format!("/api/admin/students/{student_id}/attendance"), &auth).await; + assert_eq!(status, StatusCode::OK); + let attendances: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(attendances.as_array().unwrap().len(), 1); + } + + #[sqlx::test(migrations = "./migrations")] + async fn per_week_attendance_view(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + let (slot_id, student_id, session_id) = seed_data(&pool).await; + + post_json(app.clone(), &format!("/api/admin/slots/{slot_id}/attendance"), &auth, json!({"student_id": student_id})).await; + + let (status, body) = get(app, &format!("/api/admin/sessions/{session_id}/attendance"), &auth).await; + assert_eq!(status, StatusCode::OK); + let res: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(res["attendances"].as_array().unwrap().len(), 1); + assert_eq!(res["students"].as_array().unwrap().len(), 1); + assert_eq!(res["slots"].as_array().unwrap().len(), 1); + } +} diff --git a/backend/src/routes/export.rs b/backend/src/routes/export.rs new file mode 100644 index 0000000..469e9b7 --- /dev/null +++ b/backend/src/routes/export.rs @@ -0,0 +1,313 @@ +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use axum::http::{header, StatusCode}; +use sqlx::SqlitePool; +use crate::{auth::TutorClaims, error::AppError}; +use std::fmt::Write; + +async fn export_session_csv( + State(pool): State, + claims: TutorClaims, + Path(session_id): Path, +) -> Result { + let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?") + .bind(session_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + let students: Vec = sqlx::query_as( + "SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name" + ) + .bind(course_id.0) + .fetch_all(&pool) + .await?; + + let attendance_counts: Vec<(i64,)> = sqlx::query_as( + r#" + SELECT student_id FROM attendances + WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?) + "# + ) + .bind(session_id) + .fetch_all(&pool) + .await?; + + let attended_student_ids: std::collections::HashSet = attendance_counts.into_iter().map(|(id,)| id).collect(); + + let mut csv = String::from("Student,Present\n"); + for student in students { + let present = attended_student_ids.contains(&student.id); + writeln!(csv, "{},{}", student.name, present).unwrap(); + } + + Ok(( + [(header::CONTENT_TYPE, "text/csv")], + csv + ).into_response()) +} + +async fn export_session_md( + State(pool): State, + claims: TutorClaims, + Path(session_id): Path, +) -> Result { + let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?") + .bind(session_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + let students: Vec = sqlx::query_as( + "SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name" + ) + .bind(course_id.0) + .fetch_all(&pool) + .await?; + + let attendance_counts: Vec<(i64,)> = sqlx::query_as( + r#" + SELECT student_id FROM attendances + WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?) + "# + ) + .bind(session_id) + .fetch_all(&pool) + .await?; + + let attended_student_ids: std::collections::HashSet = attendance_counts.into_iter().map(|(id,)| id).collect(); + + let mut md = String::from("| Student | Present |\n|---------|---------|\n"); + for student in students { + let present = if attended_student_ids.contains(&student.id) { "✓" } else { " " }; + writeln!(md, "| {} | {} |", student.name, present).unwrap(); + } + + Ok(( + [(header::CONTENT_TYPE, "text/markdown")], + md + ).into_response()) +} + +async fn export_course_csv( + State(pool): State, + claims: TutorClaims, + Path(course_id): Path, +) -> Result { + super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + + let students: Vec = sqlx::query_as( + "SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name" + ) + .bind(course_id) + .fetch_all(&pool) + .await?; + + let sessions: Vec = sqlx::query_as( + "SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr" + ) + .bind(course_id) + .fetch_all(&pool) + .await?; + + let mut csv = String::from("Student"); + for session in &sessions { + write!(csv, ",Week {}", session.week_nr).unwrap(); + } + csv.push_str(",Bonus\n"); + + for student in students { + write!(csv, "{}", student.name).unwrap(); + let mut unexcused_absences = 0; + for session in &sessions { + let attended: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) FROM attendances + WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?) + "# + ) + .bind(student.id) + .bind(session.id) + .fetch_one(&pool) + .await?; + + if attended.0 > 0 { + csv.push_str(",true"); + } else { + csv.push_str(",false"); + unexcused_absences += 1; + } + } + let bonus = if unexcused_absences <= 1 { 3 } else { 0 }; + writeln!(csv, ",{}", bonus).unwrap(); + } + + Ok(( + [(header::CONTENT_TYPE, "text/csv")], + csv + ).into_response()) +} + +async fn export_course_md( + State(pool): State, + claims: TutorClaims, + Path(course_id): Path, +) -> Result { + super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + + let students: Vec = sqlx::query_as( + "SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name" + ) + .bind(course_id) + .fetch_all(&pool) + .await?; + + let sessions: Vec = sqlx::query_as( + "SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr" + ) + .bind(course_id) + .fetch_all(&pool) + .await?; + + let mut md = String::from("| Student |"); + let mut separator = String::from("|---------|"); + for session in &sessions { + write!(md, " Week {} |", session.week_nr).unwrap(); + separator.push_str("--------|"); + } + md.push_str(" Bonus |\n"); + separator.push_str("-------|\n"); + md.push_str(&separator); + + for student in students { + write!(md, "| {} |", student.name).unwrap(); + let mut unexcused_absences = 0; + for session in &sessions { + let attended: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) FROM attendances + WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?) + "# + ) + .bind(student.id) + .bind(session.id) + .fetch_one(&pool) + .await?; + + if attended.0 > 0 { + md.push_str(" ✓ |"); + } else { + md.push_str(" |"); + unexcused_absences += 1; + } + } + let bonus = if unexcused_absences <= 1 { 3 } else { 0 }; + writeln!(md, " {} |", bonus).unwrap(); + } + + Ok(( + [(header::CONTENT_TYPE, "text/markdown")], + md + ).into_response()) +} + +async fn download_backup( + State(pool): State, + _claims: TutorClaims, +) -> Result { + let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S"); + let backup_path = format!("/tmp/backup-{}.sqlite", timestamp); + + sqlx::query(&format!("VACUUM INTO '{}'", backup_path)) + .execute(&pool) + .await?; + + let data = tokio::fs::read(&backup_path).await.map_err(|e| { + tracing::error!("failed to read backup file: {}", e); + AppError::BadRequest("failed to read backup".into()) + })?; + + tokio::fs::remove_file(&backup_path).await.ok(); + + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"backup-{}.sqlite\"", timestamp)), + ], + data + ).into_response()) +} + +pub fn router() -> Router { + Router::new() + .route("/api/admin/export/session/{id}/csv", get(export_session_csv)) + .route("/api/admin/export/session/{id}/md", get(export_session_md)) + .route("/api/admin/export/course/{id}/csv", get(export_course_csv)) + .route("/api/admin/export/course/{id}/md", get(export_course_md)) + .route("/api/admin/backup", get(download_backup)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::{build_test_app, get}; + use axum::http::StatusCode; + + async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) { + let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'") + .fetch_one(pool).await.unwrap(); + let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id") + .fetch_one(pool).await.unwrap(); + sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)") + .bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap(); + let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id") + .bind(course_id.0).fetch_one(pool).await.unwrap(); + let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id") + .bind(course_id.0).fetch_one(pool).await.unwrap(); + let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id") + .bind(session_id.0).bind(tutor.0).fetch_one(pool).await.unwrap(); + + // Mark present + sqlx::query("INSERT INTO attendances (slot_id, student_id, checked_in_at) VALUES (?, ?, '2026-04-28T09:00:00Z')") + .bind(slot_id.0).bind(student_id.0).execute(pool).await.unwrap(); + + (course_id.0, session_id.0, student_id.0) + } + + #[sqlx::test(migrations = "./migrations")] + async fn weekly_csv_format(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + let (_, session_id, _) = seed_data(&pool).await; + + let (status, body) = get(app, &format!("/api/admin/export/session/{session_id}/csv"), &auth).await; + assert_eq!(status, StatusCode::OK); + let csv = String::from_utf8(body.to_vec()).unwrap(); + assert!(csv.contains("Student,Present")); + assert!(csv.contains("Alice,true")); + } + + #[sqlx::test(migrations = "./migrations")] + async fn full_matrix_bonus_column(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + let (course_id, _, _) = seed_data(&pool).await; + + let (status, body) = get(app, &format!("/api/admin/export/course/{course_id}/csv"), &auth).await; + assert_eq!(status, StatusCode::OK); + let csv = String::from_utf8(body.to_vec()).unwrap(); + assert!(csv.contains("Student,Week 1,Bonus")); + assert!(csv.contains("Alice,true,3")); + } + + #[sqlx::test(migrations = "./migrations")] + async fn backup_headers(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + let (status, _) = get(app, "/api/admin/backup", &auth).await; + assert_eq!(status, StatusCode::OK); + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 6b42f3b..2bcbd57 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -8,6 +8,9 @@ mod checkin; mod courses; mod rooms; mod sessions; +mod attendance; +mod notes; +mod export; pub fn build(pool: SqlitePool) -> Router { Router::new() @@ -16,6 +19,9 @@ pub fn build(pool: SqlitePool) -> Router { .merge(courses::router()) .merge(rooms::router()) .merge(sessions::router()) + .merge(attendance::router()) + .merge(notes::router()) + .merge(export::router()) .with_state(pool) } diff --git a/backend/src/routes/notes.rs b/backend/src/routes/notes.rs new file mode 100644 index 0000000..917304d --- /dev/null +++ b/backend/src/routes/notes.rs @@ -0,0 +1,142 @@ +use axum::{ + extract::{Path, State}, + routing::{get, put}, + Json, Router, +}; +use sqlx::SqlitePool; +use crate::{auth::TutorClaims, error::AppError, models::UpsertNote}; + +async fn upsert_note( + State(pool): State, + claims: TutorClaims, + Path((slot_id, student_id)): Path<(i64, i64)>, + Json(req): Json, +) -> Result<(), AppError> { + let course_id: (i64,) = sqlx::query_as( + "SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?" + ) + .bind(slot_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + r#" + INSERT INTO notes (slot_id, student_id, tutor_id, content, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(slot_id, student_id, tutor_id) DO UPDATE SET + content = excluded.content, + updated_at = excluded.updated_at + "# + ) + .bind(slot_id) + .bind(student_id) + .bind(claims.sub) + .bind(req.content) + .bind(now) + .execute(&pool) + .await?; + + Ok(()) +} + +async fn get_slot_notes( + State(pool): State, + claims: TutorClaims, + Path(slot_id): Path, +) -> Result>, AppError> { + let course_id: (i64,) = sqlx::query_as( + "SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?" + ) + .bind(slot_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + let notes: Vec = sqlx::query_as( + "SELECT id, slot_id, student_id, tutor_id, content, updated_at FROM notes WHERE slot_id = ?" + ) + .bind(slot_id) + .fetch_all(&pool) + .await?; + + Ok(Json(notes)) +} + +async fn get_student_notes( + State(pool): State, + claims: TutorClaims, + Path(student_id): Path, +) -> Result>, AppError> { + let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM students WHERE id = ?") + .bind(student_id) + .fetch_one(&pool) + .await?; + + super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?; + + let notes: Vec = sqlx::query_as( + "SELECT id, slot_id, student_id, tutor_id, content, updated_at FROM notes WHERE student_id = ?" + ) + .bind(student_id) + .fetch_all(&pool) + .await?; + + Ok(Json(notes)) +} + +pub fn router() -> Router { + Router::new() + .route("/api/admin/slots/{slot_id}/notes/{student_id}", put(upsert_note)) + .route("/api/admin/slots/{slot_id}/notes", get(get_slot_notes)) + .route("/api/admin/students/{id}/notes", get(get_student_notes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::{build_test_app, put_json, get}; + use axum::http::StatusCode; + use serde_json::{json, Value}; + + async fn seed_data(pool: &SqlitePool) -> (i64, i64) { + let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'") + .fetch_one(pool).await.unwrap(); + let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id") + .fetch_one(pool).await.unwrap(); + sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)") + .bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap(); + let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id") + .bind(course_id.0).fetch_one(pool).await.unwrap(); + let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id") + .bind(course_id.0).fetch_one(pool).await.unwrap(); + let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id") + .bind(session_id.0).bind(tutor.0).fetch_one(pool).await.unwrap(); + (slot_id.0, student_id.0) + } + + #[sqlx::test(migrations = "./migrations")] + async fn upsert_note(pool: SqlitePool) { + let (app, auth) = build_test_app(pool.clone()).await; + let (slot_id, student_id) = seed_data(&pool).await; + + let (status, _) = put_json(app.clone(), &format!("/api/admin/slots/{slot_id}/notes/{student_id}"), &auth, json!({"content": "Good student"})).await; + assert_eq!(status, StatusCode::OK); + + let (status, body) = get(app.clone(), &format!("/api/admin/slots/{slot_id}/notes"), &auth).await; + assert_eq!(status, StatusCode::OK); + let notes: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(notes.as_array().unwrap().len(), 1); + assert_eq!(notes[0]["content"], "Good student"); + + // Update note + put_json(app.clone(), &format!("/api/admin/slots/{slot_id}/notes/{student_id}"), &auth, json!({"content": "Excellent student"})).await; + let (_, body) = get(app, &format!("/api/admin/slots/{slot_id}/notes"), &auth).await; + let notes: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(notes[0]["content"], "Excellent student"); + } +}