feat(backend): complete attendance, notes, and export APIs

This commit is contained in:
2026-04-28 05:11:33 +02:00
parent e3561b731d
commit 943463fff4
6 changed files with 717 additions and 1 deletions

27
backend/Cargo.lock generated
View File

@@ -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"

View File

@@ -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");

View File

@@ -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<SqlitePool>,
claims: TutorClaims,
Path(slot_id): Path<i64>,
Json(req): Json<ManualAttendance>,
) -> 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<SqlitePool>,
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<SqlitePool>,
claims: TutorClaims,
Path(session_id): Path<i64>,
) -> Result<Json<serde_json::Value>, 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<crate::models::Student> = 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<crate::models::Slot> = 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<crate::models::Attendance> = 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<SqlitePool>,
claims: TutorClaims,
Path(student_id): Path<i64>,
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = 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::<i64, _>("id"),
"slot_id": row.get::<i64, _>("slot_id"),
"checked_in_at": row.get::<String, _>("checked_in_at"),
"week_nr": row.get::<i64, _>("week_nr"),
"start_time": row.get::<String, _>("start_time"),
})
})
.collect();
Ok(Json(serde_json::json!(attendances)))
}
pub fn router() -> Router<SqlitePool> {
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);
}
}

View File

@@ -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<SqlitePool>,
claims: TutorClaims,
Path(session_id): Path<i64>,
) -> Result<Response, 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?;
let students: Vec<crate::models::Student> = 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<i64> = 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<SqlitePool>,
claims: TutorClaims,
Path(session_id): Path<i64>,
) -> Result<Response, 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?;
let students: Vec<crate::models::Student> = 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<i64> = 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<SqlitePool>,
claims: TutorClaims,
Path(course_id): Path<i64>,
) -> Result<Response, AppError> {
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
let students: Vec<crate::models::Student> = 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<crate::models::Session> = 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<SqlitePool>,
claims: TutorClaims,
Path(course_id): Path<i64>,
) -> Result<Response, AppError> {
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
let students: Vec<crate::models::Student> = 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<crate::models::Session> = 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<SqlitePool>,
_claims: TutorClaims,
) -> Result<Response, AppError> {
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<SqlitePool> {
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);
}
}

View File

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

142
backend/src/routes/notes.rs Normal file
View File

@@ -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<SqlitePool>,
claims: TutorClaims,
Path((slot_id, student_id)): Path<(i64, i64)>,
Json(req): Json<UpsertNote>,
) -> 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<SqlitePool>,
claims: TutorClaims,
Path(slot_id): Path<i64>,
) -> Result<Json<Vec<crate::models::Note>>, 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<crate::models::Note> = 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<SqlitePool>,
claims: TutorClaims,
Path(student_id): Path<i64>,
) -> Result<Json<Vec<crate::models::Note>>, 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<crate::models::Note> = 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<SqlitePool> {
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");
}
}