Files
tutortool/docs/plans/2026-04-27-attendance-tool.md
T
mpuchstein ff5ad26cfc feat: harden security with httpOnly cookies and modernize frontend with Svelte 5 runes
- Switched to secure httpOnly, SameSite=Strict cookies for JWT authentication.
- Refactored backend to use AppState for shared secrets and database pool caching.
- Modernized frontend with Svelte 5 runes ($state) and removed localStorage reliance.
- Gated destructive test endpoints behind debug_assertions and fixed unsafe test patterns.
- Enhanced CI pipeline with cargo clippy, cargo fmt, and pinned pnpm version.
- Updated documentation and implementation plans to match the hardened architecture.
2026-05-02 03:16:33 +02:00

52 KiB
Raw Blame History

Attendance Tracking Tool Implementation Plan

STATUS: All tasks completed. The project has been hardened and modernized as of 2026-05-02.

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a self-hosted web app (Rust/Axum + SvelteKit + SQLite) where students self-check-in to weekly tutoring sessions via a projected URL, and tutors manage sessions, attendance, and per-student notes.

Architecture: Single container on k8s — Axum serves the SvelteKit static build and a REST API at /api/*. SQLite on a PVC is the sole data store. All non-/api routes fall back to index.html for SPA routing.

Tech Stack: Rust 2021, Axum 0.7, sqlx 0.7 (SQLite), SvelteKit (adapter-static), TypeScript, Kubernetes

Spec: docs/superpowers/specs/2026-04-27-attendance-tracking-design.md

Dev location: ~/Dev/itshcloud_dev/apps/tutortool/

Design handoff: ~/Dev/FPTutor/docs/design_handoff_tutormanager/ — pixel-accurate HTML/JSX mocks + styles.css with all design tokens. Open Tutormanager.html in a browser to view all screens. Recreate in Svelte; don't ship the JSX files. See README.md in that folder for full component specs.


File Map

Backend ~/Dev/itshcloud_dev/apps/tutortool/backend/

File Responsibility
Cargo.toml dependencies
migrations/001_initial.sql all tables from spec
src/main.rs server startup, router assembly, static file fallback
src/db.rs pool init, PRAGMA foreign_keys = ON per connection
src/error.rs AppError enum → Axum IntoResponse
src/models.rs DB row types + JSON request/response structs
src/auth.rs JWT encode/decode, TutorClaims, extractor middleware
src/routes/mod.rs route assembly
src/routes/auth_routes.rs POST /api/auth/login, POST /api/auth/logout
src/routes/courses.rs /api/admin/courses, /api/admin/students
src/routes/rooms.rs /api/admin/rooms
src/routes/sessions.rs /api/admin/sessions, /api/admin/slots
src/routes/attendance.rs /api/admin/attendance (manual entry + views)
src/routes/notes.rs /api/admin/notes
src/routes/export.rs /api/admin/export/*, /api/admin/backup
src/routes/checkin.rs /api/checkin/* (student-facing, no JWT)

Frontend ~/Dev/itshcloud_dev/apps/tutortool/frontend/

File Responsibility
package.json dependencies
svelte.config.js adapter-static config
vite.config.ts dev proxy /api → backend
src/app.html HTML shell
src/lib/types.ts TypeScript types mirroring backend models
src/lib/api.ts typed fetch wrapper, error handling
src/lib/auth.ts JWT store, login/logout helpers
src/lib/RoomCanvas.svelte SVG seat map (shared: check-in + notes)
src/routes/+layout.svelte top-level layout
src/routes/login/+page.svelte tutor login form
src/routes/admin/+layout.svelte JWT auth guard
src/routes/admin/+page.svelte dashboard: slot status badges + toggle
src/routes/admin/courses/+page.svelte courses + student management
src/routes/admin/rooms/+page.svelte room list + layout editor
src/routes/admin/sessions/+page.svelte session + slot management
src/routes/admin/attendance/+page.svelte per-week / per-student tables + manual entry
src/routes/admin/notes/+page.svelte seat map + inline note editor
src/routes/admin/export/+page.svelte export + backup download
src/routes/s/[code]/+page.svelte student check-in page

K8s ~/Dev/itshcloud_dev/apps/tutortool/k8s/

deployment.yaml, service.yaml, ingress.yaml, pvc.yaml, cronjob.yaml


Task 1: Scaffold Backend

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/Cargo.toml

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/main.rs

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/error.rs

  • Step 1: Init Cargo project

cd tools/attendance
cargo init backend
  • Step 2: Write Cargo.toml
[package]
name = "attendance"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
bcrypt = "0.15"
tower-http = { version = "0.5", features = ["fs", "cors"] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = "0.3"

[dev-dependencies]
tower = { version = "0.4", features = ["util"] }
http-body-util = "0.1"
  • Step 3: Write src/error.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("database error: {0}")]
    Db(#[from] sqlx::Error),
    #[error("not found")]
    NotFound,
    #[error("conflict: {0}")]
    Conflict(String),
    #[error("unauthorized")]
    Unauthorized,
    #[error("bad request: {0}")]
    BadRequest(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, msg) = match &self {
            AppError::Db(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()),
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found".into()),
            AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
            AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
        };
        (status, Json(json!({"error": msg}))).into_response()
    }
}
  • Step 4: Write minimal src/main.rs
mod db;
mod error;
mod models;
mod auth;
mod routes;

use axum::Router;
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();

    let pool = db::init().await.expect("db init failed");
    let app = routes::build(pool);
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    tracing::info!("listening on :3000");
    axum::serve(listener, app).await.unwrap();
}
  • Step 5: Create stub files so it compiles

Create src/models.rs, src/auth.rs, src/routes/mod.rs as empty pub fn stubs.

  • Step 6: Verify it compiles
cd ~/Dev/itshcloud_dev/apps/tutortool/backend && cargo build

Expected: builds without errors.

  • Step 7: Commit
git add ~/Dev/itshcloud_dev/apps/tutortool/backend
git commit -m "feat(attendance): scaffold Rust/Axum backend"

Task 2: Database Setup

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/migrations/001_initial.sql

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/db.rs

  • Step 1: Write failing test for pool + PRAGMA

In src/db.rs:

use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};

pub async fn init() -> Result<SqlitePool, sqlx::Error> {
    let url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "sqlite:attendance.db".into());
    let pool = SqlitePoolOptions::new()
        .after_connect(|conn, _| Box::pin(async move {
            sqlx::query("PRAGMA foreign_keys = ON")
                .execute(conn).await?;
            Ok(())
        }))
        .connect(&url).await?;
    sqlx::migrate!("./migrations").run(&pool).await?;
    Ok(pool)
}

#[cfg(test)]
mod tests {
    use super::*;

    // NOTE: #[sqlx::test] injects its own pool, bypassing after_connect.
    // We test FK enforcement behaviorally: insert a row with a bad FK and assert it fails.
    // after_connect is manually invoked in the test setup below.
    #[sqlx::test(migrations = "./migrations")]
    async fn foreign_keys_enforced(pool: SqlitePool) {
        // Enable FK for this test connection (mirrors what after_connect does in production)
        sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await.unwrap();

        let err = sqlx::query(
            "INSERT INTO students (course_id, name) VALUES (999, 'Ghost')"
        ).execute(&pool).await;
        assert!(err.is_err(), "FK violation should be rejected when foreign_keys = ON");
    }
}
  • Step 2: Run test — expect it to fail
cd ~/Dev/itshcloud_dev/apps/tutortool/backend && cargo test foreign_keys_enforced

Expected: FAIL (migrations dir doesn't exist yet).

  • Step 3: Write migrations/001_initial.sql

Copy the full SQL from the spec (all 8 tables: courses, tutors, tutor_courses, students, rooms, sessions, slots, attendances, notes).

  • Step 4: Run test — expect pass
cargo test foreign_keys_enforced

Expected: PASS.

  • Step 5: Write migration idempotency test
#[sqlx::test(migrations = "./migrations")]
async fn all_tables_exist(pool: SqlitePool) {
    for table in &["courses","tutors","tutor_courses","students","rooms",
                   "sessions","slots","attendances","notes"] {
        let count: (i64,) = sqlx::query_as(
            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?"
        ).bind(table).fetch_one(&pool).await.unwrap();
        assert_eq!(count.0, 1, "table {table} missing");
    }
}
  • Step 6: Run test — expect pass
cargo test all_tables_exist
  • Step 7: Commit
git add ~/Dev/itshcloud_dev/apps/tutortool/backend/migrations ~/Dev/itshcloud_dev/apps/tutortool/backend/src/db.rs
git commit -m "feat(attendance): add SQLite migrations and db pool with FK pragma"

Task 3: Models

Files:

  • Create/Modify: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/models.rs

  • Step 1: Write all DB row structs and API types

use serde::{Deserialize, Serialize};

// --- DB rows ---
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Course { pub id: i64, pub name: String, pub semester: String }

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Tutor { pub id: i64, pub name: String, pub email: String }

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Student { pub id: i64, pub course_id: i64, pub name: String }

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Room { pub id: i64, pub name: String, pub layout_json: String }

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Session {
    pub id: i64, pub course_id: i64,
    pub week_nr: i64, pub date: String,
}

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Slot {
    pub id: i64, pub session_id: i64,
    pub room_id: Option<i64>, pub tutor_id: i64,
    pub start_time: String, pub end_time: String,
    pub status: String, pub code: Option<String>,
}

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Attendance {
    pub id: i64, pub slot_id: i64, pub student_id: i64,
    pub seat_id: Option<String>, pub checked_in_at: String,
}

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Note {
    pub id: i64, pub slot_id: i64, pub student_id: i64,
    pub tutor_id: i64, pub content: String, pub updated_at: String,
}

// --- Layout element (nested in Room) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutElement {
    pub id: String, pub label: String,
    pub x: f64, pub y: f64,
    pub width: f64, pub height: f64,
    #[serde(rename = "type")]
    pub kind: String,  // "seat" | "table" | "gap" | "door"
}

// --- Request types ---
#[derive(Deserialize)] pub struct CreateCourse { pub name: String, pub semester: String }
#[derive(Deserialize)] pub struct CreateStudent { pub name: String }
#[derive(Deserialize)] pub struct CreateRoom { pub name: String, pub layout: Vec<LayoutElement> }
#[derive(Deserialize)] pub struct CreateSession { pub course_id: i64, pub week_nr: i64, pub date: String }
#[derive(Deserialize)] pub struct CreateSlot {
    pub session_id: i64, pub room_id: Option<i64>, pub tutor_id: i64,
    pub start_time: String, pub end_time: String,
}
#[derive(Deserialize)] pub struct UpsertNote { pub content: String }
#[derive(Deserialize)] pub struct ManualAttendance { pub student_id: i64 }
#[derive(Deserialize)] pub struct CheckinRequest {
    pub code: String,
    pub student_id: i64,
    pub seat_id: Option<String>,
}
  • Step 2: Verify it compiles
cargo build
  • Step 3: Commit
git add src/models.rs
git commit -m "feat(attendance): add data models and request types"

Task 4: Tutor Auth

Files:

  • Create/Modify: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/auth.rs

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/auth_routes.rs

  • Step 1: Write failing tests

In src/auth.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn roundtrip_jwt() {
        std::env::set_var("JWT_SECRET", "testsecret");
        let token = encode_jwt(1, "test@example.com").unwrap();
        let claims = decode_jwt(&token).unwrap();
        assert_eq!(claims.sub, 1);
    }

    #[test]
    fn invalid_jwt_rejected() {
        std::env::set_var("JWT_SECRET", "testsecret");
        assert!(decode_jwt("not.a.token").is_err());
    }
}
  • Step 2: Run — expect FAIL
cargo test roundtrip_jwt
  • Step 3: Implement src/auth.rs
use axum::{extract::{FromRequestParts, Request}, http::request::Parts, middleware::Next, response::Response};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::error::AppError;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TutorClaims { pub sub: i64, pub email: String, pub exp: usize }

fn secret() -> String {
    std::env::var("JWT_SECRET").expect("JWT_SECRET not set")
}

pub fn encode_jwt(id: i64, email: &str) -> Result<String, AppError> {
    let exp = chrono::Utc::now().timestamp() as usize + 86400 * 7;
    let claims = TutorClaims { sub: id, email: email.into(), exp };
    encode(&Header::default(), &claims, &EncodingKey::from_secret(secret().as_bytes()))
        .map_err(|_| AppError::Unauthorized)
}

pub fn decode_jwt(token: &str) -> Result<TutorClaims, AppError> {
    decode::<TutorClaims>(token, &DecodingKey::from_secret(secret().as_bytes()),
        &Validation::default())
        .map(|d| d.claims)
        .map_err(|_| AppError::Unauthorized)
}

// Axum extractor: pulls JWT from Authorization: Bearer header
#[axum::async_trait]
impl<S: Send + Sync> FromRequestParts<S> for TutorClaims {
    type Rejection = AppError;
    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
        let header = parts.headers.get("authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .ok_or(AppError::Unauthorized)?;
        decode_jwt(header)
    }
}
  • Step 4: Run tests — expect PASS
cargo test roundtrip_jwt invalid_jwt_rejected
  • Step 5: Create src/test_helpers.rs

This module is used by all subsequent test tasks. Add #[cfg(test)] mod test_helpers; to main.rs.

// src/test_helpers.rs
use sqlx::SqlitePool;
use axum::Router;
use tower::ServiceExt;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;

/// Insert a test tutor, return a valid JWT for that tutor.
pub async fn make_token(pool: &SqlitePool, email: &str) -> String {
    let hash = bcrypt::hash("testpass", bcrypt::DEFAULT_COST).unwrap();
    sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash) VALUES (?,?,?)")
        .bind("Test Tutor").bind(email).bind(&hash)
        .execute(pool).await.unwrap();
    let id: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = ?")
        .bind(email).fetch_one(pool).await.unwrap();
    crate::auth::encode_jwt(id.0, email).unwrap()
}

/// Build the app and return (router, auth_header_value) for use with tower::ServiceExt.
pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
    let token = make_token(&pool, "tutor@test.com").await;
    let app = crate::routes::build(pool);
    (app, format!("Bearer {token}"))
}

/// POST JSON to an app via tower::ServiceExt::oneshot, return (status, body_bytes).
pub async fn post_json(app: Router, path: &str, auth: &str, body: serde_json::Value)
    -> (StatusCode, bytes::Bytes)
{
    let req = Request::builder()
        .method("POST").uri(path)
        .header("Content-Type", "application/json")
        .header("Authorization", auth)
        .body(axum::body::Body::from(body.to_string()))
        .unwrap();
    let res = app.oneshot(req).await.unwrap();
    let status = res.status();
    let body = res.into_body().collect().await.unwrap().to_bytes();
    (status, body)
}

/// GET from an app via tower::ServiceExt::oneshot, return (status, body_bytes).
pub async fn get(app: Router, path: &str, auth: &str) -> (StatusCode, bytes::Bytes) {
    let req = Request::builder()
        .method("GET").uri(path)
        .header("Authorization", auth)
        .body(axum::body::Body::empty())
        .unwrap();
    let res = app.clone().oneshot(req).await.unwrap();
    let status = res.status();
    let body = res.into_body().collect().await.unwrap().to_bytes();
    (status, body)
}
  • Step 6: Write login route test

In src/routes/auth_routes.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_helpers::{build_test_app, post_json};

    #[sqlx::test(migrations = "./migrations")]
    async fn login_returns_token(pool: sqlx::SqlitePool) {
        let hash = bcrypt::hash("secret", bcrypt::DEFAULT_COST).unwrap();
        sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
            .bind("Test").bind("t@test.com").bind(&hash)
            .execute(&pool).await.unwrap();

        let app = crate::routes::build(pool);
        let (status, body) = post_json(app, "/api/auth/login", "",
            serde_json::json!({"email":"t@test.com","password":"secret"})).await;
        assert_eq!(status, 200);
        assert!(serde_json::from_slice::<serde_json::Value>(&body).unwrap()["token"].is_string());
    }

    #[sqlx::test(migrations = "./migrations")]
    async fn login_wrong_password(pool: sqlx::SqlitePool) {
        let hash = bcrypt::hash("correct", bcrypt::DEFAULT_COST).unwrap();
        sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
            .bind("Test").bind("t@test.com").bind(&hash)
            .execute(&pool).await.unwrap();

        let app = crate::routes::build(pool);
        let (status, _) = post_json(app, "/api/auth/login", "",
            serde_json::json!({"email":"t@test.com","password":"wrong"})).await;
        assert_eq!(status, 401);
    }
}
  • Step 6: Implement src/routes/auth_routes.rs
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use sqlx::SqlitePool;
use serde_json::{json, Value};
use crate::{auth, error::AppError, models::Tutor};

#[derive(Deserialize)]
struct LoginRequest { email: String, password: String }

async fn login(
    State(pool): State<SqlitePool>,
    Json(req): Json<LoginRequest>,
) -> Result<Json<Value>, AppError> {
    let tutor: Option<(i64, String, String)> = sqlx::query_as(
        "SELECT id, email, password_hash FROM tutors WHERE email = ?"
    ).bind(&req.email).fetch_optional(&pool).await?;

    let (id, email, hash) = tutor.ok_or(AppError::Unauthorized)?;
    if !bcrypt::verify(&req.password, &hash).unwrap_or(false) {
        return Err(AppError::Unauthorized);
    }
    let token = auth::encode_jwt(id, &email)?;
    Ok(Json(json!({"token": token})))
}

pub fn router() -> Router<SqlitePool> {
    Router::new().route("/api/auth/login", post(login))
}
  • Step 7: Wire up routes in src/routes/mod.rs
use axum::Router;
use sqlx::SqlitePool;

mod auth_routes;

pub fn build(pool: SqlitePool) -> Router {
    Router::new()
        .merge(auth_routes::router())
        .with_state(pool)
}
  • Step 7: Implement src/routes/auth_routes.rs (already shown above — no changes needed)

  • Step 8: Run tests — expect PASS

cargo test login_returns_token login_wrong_password
  • Step 9: Commit
git add src/auth.rs src/routes/ src/test_helpers.rs
git commit -m "feat(attendance): add JWT auth, login endpoint, and test helpers"

Task 5: Courses & Students API

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/courses.rs

  • Step 1: Write tests

#[sqlx::test(migrations = "./migrations")]
async fn create_and_list_courses(pool: SqlitePool) {
    let (app, auth) = build_test_app(pool.clone()).await;
    let (status, body) = post_json(app.clone(), "/api/admin/courses", &auth,
        json!({"name":"FP","semester":"SS2026"})).await;
    assert_eq!(status, 201);
    let id = serde_json::from_slice::<Value>(&body).unwrap()["id"].as_i64().unwrap();

    let (status, body) = get(app, "/api/admin/courses", &auth).await;
    assert_eq!(status, 200);
    assert!(serde_json::from_slice::<Value>(&body).unwrap()
        .as_array().unwrap().iter().any(|c| c["id"] == id));
}

#[sqlx::test(migrations = "./migrations")]
async fn create_student_and_list(pool: SqlitePool) {
    let (app, auth) = build_test_app(pool.clone()).await;
    // POST /api/admin/courses → get course id
    // POST /api/admin/courses/:id/students {name:"Alice"}
    // GET /api/admin/courses/:id/students → assert Alice in list
}
  • Step 2: Run — expect FAIL

  • Step 3: Implement src/routes/courses.rs

Endpoints:

  • GET /api/admin/courses → list all courses
  • POST /api/admin/courses → create course (requires TutorClaims extractor)
  • GET /api/admin/courses/:id/students → list students for course
  • POST /api/admin/courses/:id/students → add student
  • POST /api/admin/courses/:id/students/import → CSV import (multipart, name column)
  • DELETE /api/admin/students/:id → delete student

All mutating endpoints extract TutorClaims to require auth.

  • Step 4: Run tests — expect PASS

  • Step 5: Commit

git commit -m "feat(attendance): add courses and students CRUD endpoints"

Task 6: Rooms API

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/rooms.rs

  • Step 1: Write tests

#[sqlx::test]
async fn create_room_with_layout(pool: SqlitePool) {
    let layout = json!([
        {"id":"s1","label":"A1","x":0,"y":0,"width":1,"height":1,"type":"seat"},
        {"id":"s2","label":"A2","x":1,"y":0,"width":1,"height":1,"type":"seat"}
    ]);
    // POST /api/admin/rooms with {name, layout}
    // assert 201, returned id
    // GET /api/admin/rooms/:id
    // assert layout matches
}

#[sqlx::test]
async fn update_room_layout(pool: SqlitePool) {
    // create room, update layout, verify new layout returned
}
  • Step 2: Run — expect FAIL

  • Step 3: Implement src/routes/rooms.rs

Endpoints (all require TutorClaims auth):

  • GET /api/admin/rooms → list all rooms (id, name only)
  • POST /api/admin/rooms → create room; layout field serialized to JSON string
  • GET /api/admin/rooms/{id} → room with parsed layout (Vec)
  • PUT /api/admin/rooms/{id}/layout → replace layout JSON

Layout validation (reject with 400 on failure):

  • Must parse as Vec<LayoutElement>
  • Each element id must be non-empty and unique within the layout
  • type must be one of: "seat", "table", "gap", "door"
  • x, y, width, height must all be ≥ 0.0
  • No two elements may share the same label when type == "seat" (seat labels must be unique per room)

Extract validation into a fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> helper.

Layout JSON is stored as a TEXT column; backend parses with serde_json::from_str::<Vec<LayoutElement>> on read, validates on write.

  • Step 4: Run tests — expect PASS

  • Step 5: Commit

git commit -m "feat(attendance): add rooms CRUD with flexible layout JSON"

Task 7: Sessions, Slots & Code Generation

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/sessions.rs

  • Step 1: Write code generation unit test

// in sessions.rs
fn generate_code() -> String {
    const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I
    use rand::Rng;
    let mut rng = rand::thread_rng();
    (0..8).map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;

    #[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, "expected high uniqueness");
    }
}
  • Step 2: Run — expect PASS (pure logic, no DB needed)

  • Step 3: Write slot status machine test

#[sqlx::test]
async fn open_slot_sets_code_atomically(pool: SqlitePool) {
    // create course, tutor, session, slot (status=closed)
    // PATCH /api/admin/slots/:id/status {status: "open"}
    // assert response: status=open, code is 8-char string
    // GET /api/admin/slots/:id → same code still set
}

  • Step 4: Run — expect FAIL

  • Step 5: Implement src/routes/sessions.rs

All endpoints require TutorClaims auth. Use super::verify_tutor_course_access(&pool, claims.sub, course_id).await? on every mutating endpoint.

Endpoints:

  • GET /api/admin/sessions?course_id= → sessions with slots embedded; verify tutor has access to course_id
  • POST /api/admin/sessions → create session; verify tutor access to course_id; validate date is a valid YYYY-MM-DD; week_nr must be positive
  • POST /api/admin/slots → create slot; validate start_time < end_time; verify tutor_id belongs to tutor_courses for the session's course; verify room_id exists (if provided)
  • PATCH /api/admin/slots/{id}/status → status transition; when → open, atomically set code = generate_code() with retry on UNIQUE collision; when → closed/locked, do not touch code (URL stays accessible read-only); verify tutor access to slot's course
  • DELETE /api/admin/slots/{id} → only if status = closed; verify tutor access

rand 0.9 note: use rand::rng() instead of rand::thread_rng() in generate_code().

  • Step 6: Run tests — expect PASS

  • Step 7: Commit

git commit -m "feat(attendance): sessions/slots CRUD with atomic code generation"

Task 8: Student Check-in API

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/checkin.rs

This is the most critical module — FCFS seat locking and cookie-based identity.

  • Step 1: Write tests
#[sqlx::test(migrations = "./migrations")]
async fn cannot_use_closed_slot_code(pool: SqlitePool) {
    // seed open slot, close it, then GET /api/checkin/:code → assert 404
}

#[sqlx::test(migrations = "./migrations")]
async fn locked_slot_still_accessible_read_only(pool: SqlitePool) {
    // seed open slot with a student checked in, lock the slot
    // GET /api/checkin/:code → assert 200 (read-only, not 404)
}

#[sqlx::test(migrations = "./migrations")]
async fn checkin_selects_seat(pool: SqlitePool) {
    // seed: course, student, room (2 seats), session, open slot
    // POST /api/checkin {code, student_id, seat_id:"s1"}
    // assert 200, attendance row exists with seat_id="s1"
}

#[sqlx::test(migrations = "./migrations")]
async fn second_student_cant_take_same_seat(pool: SqlitePool) {
    // seed same as above, student A takes s1
    // POST /api/checkin with student B, seat_id s1
    // assert 409
}

#[sqlx::test(migrations = "./migrations")]
async fn seat_change_frees_old_seat(pool: SqlitePool) {
    // student takes s1, then POSTs again with s2
    // assert s1 freed (no attendance row for student+s1), s2 taken
}

#[sqlx::test(migrations = "./migrations")]
async fn checkin_on_locked_slot_rejected(pool: SqlitePool) {
    // lock slot, attempt checkin
    // assert 409 with message "check-in not available"
}

#[sqlx::test(migrations = "./migrations")]
async fn seat_null_rejected_for_layout_slot(pool: SqlitePool) {
    // slot has room_id set, POST with seat_id omitted
    // assert 400
}

#[sqlx::test(migrations = "./migrations")]
async fn students_from_other_course_not_in_dropdown(pool: SqlitePool) {
    // two courses, each with one student, one open slot for course A
    // GET /api/checkin/:code/students → only returns course A's student
}
  • Step 2: Run — expect FAIL

  • Step 3: Implement src/routes/checkin.rs

Endpoints:

  • GET /api/checkin/{code} → slot info + layout (if room set); returns {slot, layout, attendances: [{seat_id, is_mine}]} where is_mine is set based on student_id cookie; unknown code or status = closed → 404
  • GET /api/checkin/{code}/students → list students filtered to slot's course (no auth required)
  • POST /api/checkin body: {code, student_id, seat_id?}:
    1. Look up slot by code → 404 if not found or status = closed
    2. Reject if slot.status != "open" → 409 "check-in not available"
    3. If room_id IS NOT NULL and seat_id IS NULL → 400 "seat required"
    4. seat_id validation: if seat_id is provided, parse the room's layout_json and verify seat_id exists in layout with type = "seat" → 400 "invalid seat" otherwise
    5. Cookie identity check: if cookie attendance_identity is set for this code, its student_id must match the request's student_id → 409 "identity mismatch" if different
    6. If existing attendance for (slot_id, student_id): delete in same transaction (seat change)
    7. Insert new attendance; on UNIQUE(slot_id, seat_id) conflict → 409 "seat taken"
    8. Set/refresh attendance_identity cookie: {code, student_id}, httpOnly, SameSite=Strict, 24h

Cookie is read to determine is_mine on GET and to enforce identity on POST.

  • Step 4: Run tests — expect PASS

  • Step 5: Commit

git commit -m "feat(attendance): student check-in API with FCFS seat locking"

Task 9: Admin Attendance & Notes APIs

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/attendance.rs

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/notes.rs

  • Step 1: Write tests for attendance

#[sqlx::test]
async fn manual_attendance_entry(pool: SqlitePool) {
    // POST /api/admin/slots/:id/attendance {student_id} with no seat_id
    // assert row exists with seat_id=null
}

#[sqlx::test]
async fn per_week_attendance_view(pool: SqlitePool) {
    // seed session with 2 slots, mark some students present
    // GET /api/admin/sessions/:id/attendance
    // assert response has all students with present:true/false per slot
}
  • Step 2: Write tests for notes
#[sqlx::test]
async fn upsert_note(pool: SqlitePool) {
    // POST /api/admin/slots/:slot_id/notes/:student_id {content:"good work"}
    // assert 200, content saved
    // POST again with {content:"updated"} → assert updated, not duplicated
}
  • Step 3: Run — expect FAIL

  • Step 4: Implement attendance endpoints

  • POST /api/admin/slots/:id/attendance — manual entry (auth required); seat_id = NULL

  • DELETE /api/admin/slots/:slot_id/attendance/:student_id — remove attendance

  • GET /api/admin/sessions/:id/attendance — all students × slots matrix for session

  • GET /api/admin/students/:id/attendance — all sessions × slots for one student

  • Step 5: Implement notes endpoints

  • PUT /api/admin/slots/:slot_id/notes/:student_id — upsert note (INSERT OR REPLACE); sets updated_at = now()

  • GET /api/admin/slots/:slot_id/notes — all notes for slot (tutor-only)

  • GET /api/admin/students/:id/notes — all notes for student across slots

  • Step 6: Run tests — expect PASS

  • Step 7: Commit

git commit -m "feat(attendance): manual attendance entry and tutor notes API"

Task 10: Export API

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/export.rs

  • Step 1: Write tests

#[sqlx::test(migrations = "./migrations")]
async fn weekly_csv_format(pool: SqlitePool) {
    // seed: course with 3 students, session with 2 slots, 2 students checked in to different slots
    // GET /api/admin/export/session/:id/csv
    // assert Content-Type: text/csv
    // assert first line == "Student,Present"   ← merged per-session (present if attended ANY slot)
    // assert all 3 students appear: 2 with "true", 1 with "false"
}

#[sqlx::test(migrations = "./migrations")]
async fn full_matrix_bonus_column(pool: SqlitePool) {
    // Bonus rule: student gets 3 bonus points if unexcused absences <= 1.
    // "Unexcused absence" = no attendance record for any slot in that session.
    // Seed: course, 4 sessions, student attends sessions 1+2+3 (misses 4).
    // 1 unexcused absence → Bonus = 3.
    // Seed second student who attends sessions 1+2 only (misses 3+4).
    // 2 unexcused absences → Bonus = 0.
    // GET /api/admin/export/course/:id/csv
    // assert student1 Bonus = 3, student2 Bonus = 0
}

// Backup test: #[sqlx::test] uses a temp DB (not a real file path), so we only
// test response headers. The full byte-content test is an integration test run
// with a real DATABASE_URL pointing to a file.
#[sqlx::test(migrations = "./migrations")]
async fn backup_headers(pool: SqlitePool) {
    // GET /api/admin/backup (with auth)
    // When DATABASE_URL points to a real file: assert Content-Type: application/octet-stream
    // assert Content-Disposition contains "attachment"
    // Note: in unit test the handler may return 500 if the DB file doesn't exist;
    // that is acceptable — mark this as an integration-only test with #[ignore] if needed.
}
  • Step 2: Run — expect FAIL

  • Step 3: Implement src/routes/export.rs

All endpoints require TutorClaims auth. Verify tutor has access to the course before generating any export.

Endpoints:

  • GET /api/admin/export/session/{id}/csv — weekly CSV, merged per-session: columns Student,Present — student is true if they attended ANY slot in that session, false if no attendance across all slots
  • GET /api/admin/export/session/{id}/md — same data as weekly CSV but Markdown table (pandoc-ready)
  • GET /api/admin/export/course/{id}/csv — full matrix: Student,Week1,Week2,...,Bonus; Bonus = 3 if unexcused absences ≤ 1 else 0
  • GET /api/admin/export/course/{id}/md — full matrix Markdown
  • GET /api/admin/backup — use VACUUM INTO '/tmp/backup-<timestamp>.sqlite' then stream the result file as application/octet-stream; delete the temp file after streaming. This avoids inconsistent backups from raw file copy under write load.

Markdown format:

| Student | Week 1 | Week 2 | Bonus |
|---------|--------|--------|-------|
| Alice   | ✓      |        | 3     |
  • Step 4: Run tests — expect PASS

  • Step 5: Commit

git commit -m "feat(attendance): CSV, Markdown, and SQLite backup export endpoints"

Task 11: Static File Serving & Route Assembly

Files:

  • Modify: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/main.rs

  • Modify: ~/Dev/itshcloud_dev/apps/tutortool/backend/src/routes/mod.rs

  • Step 1: Write SPA fallback test

This test requires a built frontend. Before running, create a minimal placeholder:

mkdir -p ~/Dev/itshcloud_dev/apps/tutortool/frontend/build
echo '<!doctype html><html><body>app</body></html>' > ~/Dev/itshcloud_dev/apps/tutortool/frontend/build/index.html
#[sqlx::test(migrations = "./migrations")]
async fn unknown_path_returns_index_html(pool: SqlitePool) {
    std::env::set_var("STATIC_DIR", concat!(env!("CARGO_MANIFEST_DIR"), "/../frontend/build"));
    let app = crate::routes::build(pool);
    // tower::ServiceExt::oneshot GET /some/unknown/route
    // assert status 200
    // assert body contains "<!doctype html"
}
  • Step 2: Wire all routers in routes/mod.rs
pub fn build(pool: SqlitePool) -> Router {
    Router::new()
        .merge(auth_routes::router())
        .merge(courses::router())
        .merge(rooms::router())
        .merge(sessions::router())
        .merge(attendance::router())
        .merge(notes::router())
        .merge(export::router())
        .merge(checkin::router())
        .with_state(pool)
}
  • Step 3: Add static file + SPA fallback in main.rs
use tower_http::services::{ServeDir, ServeFile};

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")))
    );
  • Step 4: Run test — expect PASS
cargo test unknown_path_returns_index_html
  • Step 5: Run full test suite
cargo test

Expected: all tests PASS.

  • Step 6: Commit
git commit -m "feat(attendance): wire all routes, add SPA static file fallback"

Task 12: Scaffold SvelteKit Frontend

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/package.json

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/svelte.config.js

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/vite.config.ts

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/app.html

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/lib/types.ts

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/lib/api.ts

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/lib/auth.ts

  • Step 1: Init SvelteKit project

cd tools/attendance
npm create svelte@latest frontend
# Choose: Skeleton, TypeScript, no extras
cd frontend && npm install
npm install -D @sveltejs/adapter-static
  • Step 2: Configure svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
    kit: {
        adapter: adapter({ fallback: 'index.html' }),
    }
};
  • Step 3: Configure vite.config.ts dev proxy
import { sveltekit } from '@sveltejs/kit/vite';
export default {
    plugins: [sveltekit()],
    server: {
        proxy: { '/api': 'http://localhost:3000' }
    }
};
  • Step 4: Write src/lib/types.ts

Mirror all backend models: Course, Student, Room, LayoutElement, Session, Slot, Attendance, Note. These must match the JSON shape returned by the API.

  • Step 5: Write src/lib/api.ts
const BASE = '/api';

async function request<T>(path: string, init?: RequestInit): Promise<T> {
    const token = localStorage.getItem('token');
    const res = await fetch(BASE + path, {
        ...init,
        headers: {
            'Content-Type': 'application/json',
            ...(token ? { Authorization: `Bearer ${token}` } : {}),
            ...init?.headers,
        }
    });
    if (!res.ok) throw new Error(await res.text());
    return res.json() as Promise<T>;
}

export const api = {
    login: (email: string, password: string) =>
        request<{token: string}>('/auth/login', { method:'POST', body: JSON.stringify({email,password}) }),
    getCourses: () => request<Course[]>('/admin/courses'),
    // ... one function per endpoint
};
  • Step 6: Write src/lib/auth.ts
import { writable } from 'svelte/store';

// Use browser guard — localStorage is undefined during SSR prerendering.
import { browser } from '$app/environment';
export const token = writable<string | null>(browser ? localStorage.getItem('token') : null);

export function setToken(t: string) {
    localStorage.setItem('token', t);
    token.set(t);
}

export function clearToken() {
    localStorage.removeItem('token');
    token.set(null);
}
  • Step 7: Verify dev build
npm run build

Expected: build/ directory created without errors.

  • Step 8: Commit
git add ~/Dev/itshcloud_dev/apps/tutortool/frontend
git commit -m "feat(attendance): scaffold SvelteKit frontend with API client"

Task 13: Login Page & Admin Auth Guard

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/login/+page.svelte

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/admin/+layout.svelte

  • Step 1: Write login/+page.svelte

Form with email + password inputs. On submit: call api.login(), store token, redirect to /admin. On error: show "Invalid credentials".

  • Step 2: Write admin/+layout.svelte
<script lang="ts">
import { token } from '$lib/auth';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';

onMount(() => {
    if (!$token) goto('/login');
});
</script>

{#if $token}
    <slot />
{/if}
  • Step 3: Test manually
cd ~/Dev/itshcloud_dev/apps/tutortool/backend && DATABASE_URL=sqlite:test.db JWT_SECRET=dev cargo run &
cd ~/Dev/itshcloud_dev/apps/tutortool/frontend && npm run dev

Open http://localhost:5173/admin — should redirect to /login. Log in with a seeded tutor → should redirect back to /admin.

  • Step 4: Commit
git commit -m "feat(attendance): login page and admin auth guard"

Task 14: Dashboard & Slot Management

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/admin/+page.svelte

  • Step 1: Implement dashboard

Loads all sessions with slots. Displays per-slot:

  • Status badge (closed / open / locked)

  • Tutor name, time, room

  • Buttons: Open / Lock / Close (calls PATCH /api/admin/slots/:id/status)

  • When open: show check-in link with copy button

  • Visual design: placeholder classes, Claude Design will supply styling

  • Step 2: Test manually — create a session + slot via API, verify dashboard shows it and status toggle works.

  • Step 3: Commit

git commit -m "feat(attendance): admin dashboard with slot status management"

Task 15: Courses & Students UI

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/admin/courses/+page.svelte

  • Step 1: Implement page

  • List all courses

  • Create course form (name + semester)

  • Per-course: list students, add student form, CSV upload button (calls import endpoint)

  • Delete student

  • Step 2: Test manually — create course, add a few students, verify list updates.

  • Step 3: Commit

git commit -m "feat(attendance): courses and student management UI"

Task 16: Room Layout Editor

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/lib/RoomCanvas.svelte
  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/admin/rooms/+page.svelte

RoomCanvas is used in three places: the layout editor (admin), the check-in page (students), and the notes view (tutor). It must accept props for each use:

// RoomCanvas.svelte props
export let layout: LayoutElement[] = [];
export let attendances: {seat_id: string}[] = [];
export let ownSeatId: string | null = null;   // highlight (check-in)
export let notes: Record<string, string> = {};  // show note badge (tutor)
export let editable = false;                   // layout editor mode
export let onSeatClick: (id: string) => void = () => {};
  • Step 1: Implement RoomCanvas.svelte

SVG-based. Maps each LayoutElement to a <rect> + <text>. Seats:

  • editable=true: draggable, add/remove seat buttons

  • Check-in: free = clickable, occupied = grey, own = highlighted

  • Notes: show dot badge if note exists for that seat's student

  • Step 2: Implement rooms/+page.svelte

  • List rooms

  • Create room

  • Per room: open layout editor (RoomCanvas with editable=true), save layout

  • Step 3: Test manually — create a room, add seats via editor, verify layout saves and loads correctly.

  • Step 4: Commit

git commit -m "feat(attendance): RoomCanvas component and room layout editor"

Task 17: Sessions & Slots UI

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/admin/sessions/+page.svelte

  • Step 1: Implement page

  • List sessions grouped by week

  • Create session form (course, week_nr, date)

  • Per session: add slot form (room, tutor, start/end time)

  • Per slot: link to dashboard for status toggle

  • Step 2: Test manually — create session, add slot.

  • Step 3: Commit

git commit -m "feat(attendance): sessions and slots management UI"

Task 18: Student Check-in Page

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/s/[code]/+page.svelte

  • Step 1: Implement page

  1. Load slot info from GET /api/checkin/:code. 404 → "Invalid or expired link."
  2. If no identity cookie for this code: show student name dropdown (load from /api/checkin/:code/students). On select: set local state (not yet checked in).
  3. Render RoomCanvas with attendances from API. Mark ownSeatId from existing attendance if any.
  4. If status = open: clicking a free seat calls POST /api/checkin. 409 → toast "Seat taken, please choose another." Success → refresh layout.
  5. If status = locked/closed: RoomCanvas is read-only. Show "Check-in closed" banner.
  6. Poll GET /api/checkin/:code every 5 seconds to refresh seat availability.
  • Step 2: Test manually

Open slot, open check-in URL in two browser tabs. Take the same seat in both — second should get "Seat taken". Change seat. Lock slot — verify read-only.

  • Step 3: Commit
git commit -m "feat(attendance): student check-in page with FCFS seat selection"

Task 19: Attendance & Notes UI

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/admin/attendance/+page.svelte

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/admin/notes/+page.svelte

  • Step 1: Implement attendance page

  • Course + session selector

  • Per-week table: rows = students, columns = slots, cells = ✓/

  • Per-student view toggle: rows = sessions, column = present/absent

  • Manual entry: select student → mark present for a slot (calls POST /api/admin/slots/:id/attendance)

  • Remove attendance button per cell

  • Step 2: Implement notes page

  • Slot selector

  • RoomCanvas with notes prop — renders dot badge on seats with notes

  • Click seat → inline text area for note, auto-save on blur (calls PUT /api/admin/slots/:slot_id/notes/:student_id)

  • Also show list view: student name + note text below the canvas

  • Step 3: Test manually — check in a student, open notes page, click seat, add note, verify it persists.

  • Step 4: Commit

git commit -m "feat(attendance): attendance management and tutor notes UI"

Task 20: Export UI

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/frontend/src/routes/admin/export/+page.svelte

  • Step 1: Implement page

  • Course selector

  • Weekly export: session selector → "Download CSV" / "Download Markdown" buttons

  • Full course export: "Download CSV" / "Download Markdown" buttons

  • Backup: "Download SQLite" button (direct link to /api/admin/backup)

  • Show pandoc hint: pandoc attendance.md -o attendance.pdf

  • Step 2: Test manually — download weekly CSV, verify columns. Download full matrix, verify Bonus column.

  • Step 3: Commit

git commit -m "feat(attendance): export and backup UI"

Task 21: Local Dev Environment (Makefile)

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/Makefile

  • Step 1: Write Makefile

.PHONY: dev dev-backend dev-frontend test build compose-up compose-down

# Starts backend (cargo-watch) and frontend (vite) in parallel.
# Requires: cargo-watch (`cargo install cargo-watch`), node/npm.
dev:
	@trap 'kill 0' SIGINT; \
	$(MAKE) dev-backend & \
	$(MAKE) dev-frontend & \
	wait

dev-backend:
	cd backend && \
	DATABASE_URL=sqlite:./dev.db \
	JWT_SECRET=devsecret \
	cargo watch -x run

dev-frontend:
	cd frontend && npm run dev

# Runs the full Rust test suite (uses in-memory SQLite via #[sqlx::test]).
test:
	cd backend && cargo test

# Builds the SvelteKit static output, then the release Rust binary.
build:
	cd frontend && npm run build
	cd backend && STATIC_DIR=../frontend/build cargo build --release

# Builds and runs the production Docker image locally (mirrors k8s).
compose-up:
	docker compose up --build

compose-down:
	docker compose down
  • Step 2: Create docker-compose.yml (for make compose-up)
services:
  tutortool:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - tutortool-db:/data
    environment:
      DATABASE_URL: sqlite:/data/attendance.db
      JWT_SECRET: devsecret
      STATIC_DIR: /app/static
volumes:
  tutortool-db:
  • Step 3: Verify
# Install cargo-watch if needed
cargo install cargo-watch

# Start dev environment
make dev

Open http://localhost:5173 — Vite dev server with hot reload, API proxied to :3000.

# Run tests
make test

Expected: all backend tests PASS.

  • Step 4: Commit
git add Makefile docker-compose.yml
git commit -m "feat(tutortool): add Makefile for dev/test/build/compose workflows"

Task 22: Dockerfile & K8s Manifests

Files:

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/Dockerfile

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/k8s/deployment.yaml

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/k8s/service.yaml

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/k8s/ingress.yaml

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/k8s/pvc.yaml

  • Create: ~/Dev/itshcloud_dev/apps/tutortool/k8s/cronjob.yaml

  • Step 1: Write multi-stage Dockerfile

# Stage 1: build frontend
FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# Stage 2: build backend
FROM rust:1.77-alpine AS backend-build
RUN apk add --no-cache musl-dev
WORKDIR /app/backend
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src ./src
COPY backend/migrations ./migrations
RUN cargo build --release

# Stage 3: runtime — sqlite3 CLI required by backup CronJob via shared PVC
FROM alpine:3.19
RUN apk add --no-cache sqlite
WORKDIR /app
COPY --from=backend-build /app/backend/target/release/attendance .
COPY --from=frontend-build /app/frontend/build ./static
ENV DATABASE_URL=sqlite:/data/attendance.db
ENV STATIC_DIR=/app/static
EXPOSE 3000
CMD ["./attendance"]
  • Step 2: Write k8s/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: attendance-db
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 1Gi
  • Step 3: Write k8s/deployment.yaml

Mount PVC at /data. Set DATABASE_URL, JWT_SECRET (from Secret). Single replica.

  • Step 4: Write k8s/service.yaml and k8s/ingress.yaml

Service: ClusterIP on port 3000. Ingress: tutor.puchstein.dev → service.

  • Step 5: Write k8s/cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: attendance-backup
spec:
  schedule: "0 3 * * *"  # 3 AM daily
  jobTemplate:
    spec:
      template:
        spec:
          volumes:
          - name: db
            persistentVolumeClaim:
              claimName: attendance-db
          containers:
          - name: backup
            image: alpine:3.19
            command:
            - sh
            - -c
            - |
              sqlite3 /data/attendance.db "VACUUM INTO '/data/backup-$(date +%F).sqlite'"
              ls -t /data/backup-*.sqlite | tail -n +8 | xargs -r rm -f
            volumeMounts:
            - name: db
              mountPath: /data
          restartPolicy: OnFailure
  • Step 6: Test Docker build locally
cd tools/attendance && docker build -t attendance:local .
docker run -p 3000:3000 -e JWT_SECRET=test -e DATABASE_URL=sqlite:/tmp/test.db attendance:local

Open http://localhost:3000 — should serve the SvelteKit app.

  • Step 7: Commit
git add ~/Dev/itshcloud_dev/apps/tutortool/Dockerfile ~/Dev/itshcloud_dev/apps/tutortool/k8s
git commit -m "feat(attendance): Dockerfile and k8s manifests with daily backup CronJob"

Done

All tasks complete. The tool is ready for:

  1. Frontend visual design integration from Claude Design
  2. k8s deployment (kubectl apply -f k8s/)
  3. First tutor account creation (seed script or direct DB insert)