- 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.
52 KiB
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 coursesPOST /api/admin/courses→ create course (requiresTutorClaimsextractor)GET /api/admin/courses/:id/students→ list students for coursePOST /api/admin/courses/:id/students→ add studentPOST /api/admin/courses/:id/students/import→ CSV import (multipart,namecolumn)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;layoutfield serialized to JSON stringGET /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
idmust be non-empty and unique within the layout typemust be one of:"seat","table","gap","door"x,y,width,heightmust all be ≥ 0.0- No two elements may share the same
labelwhentype == "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_idPOST /api/admin/sessions→ create session; verify tutor access to course_id; validatedateis a valid YYYY-MM-DD;week_nrmust be positivePOST /api/admin/slots→ create slot; validatestart_time < end_time; verifytutor_idbelongs totutor_coursesfor the session's course; verifyroom_idexists (if provided)PATCH /api/admin/slots/{id}/status→ status transition; when→ open, atomically setcode = generate_code()with retry on UNIQUE collision; when→ closed/locked, do not touchcode(URL stays accessible read-only); verify tutor access to slot's courseDELETE /api/admin/slots/{id}→ only ifstatus = 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}]}whereis_mineis set based onstudent_idcookie; unknown code orstatus = closed→ 404GET /api/checkin/{code}/students→ list students filtered to slot's course (no auth required)POST /api/checkinbody:{code, student_id, seat_id?}:- Look up slot by
code→ 404 if not found orstatus = closed - Reject if
slot.status != "open"→ 409 "check-in not available" - If
room_id IS NOT NULLandseat_id IS NULL→ 400 "seat required" - seat_id validation: if
seat_idis provided, parse the room'slayout_jsonand verifyseat_idexists in layout withtype = "seat"→ 400 "invalid seat" otherwise - Cookie identity check: if cookie
attendance_identityis set for thiscode, itsstudent_idmust match the request'sstudent_id→ 409 "identity mismatch" if different - If existing attendance for
(slot_id, student_id): delete in same transaction (seat change) - Insert new attendance; on UNIQUE(slot_id, seat_id) conflict → 409 "seat taken"
- Set/refresh
attendance_identitycookie:{code, student_id}, httpOnly, SameSite=Strict, 24h
- Look up slot by
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); setsupdated_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: columnsStudent,Present— student istrueif they attended ANY slot in that session,falseif no attendance across all slotsGET /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 0GET /api/admin/export/course/{id}/md— full matrix MarkdownGET /api/admin/backup— useVACUUM INTO '/tmp/backup-<timestamp>.sqlite'then stream the result file asapplication/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.tsdev 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
- Load slot info from
GET /api/checkin/:code. 404 → "Invalid or expired link." - 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). - Render
RoomCanvaswithattendancesfrom API. MarkownSeatIdfrom existing attendance if any. - If
status = open: clicking a free seat callsPOST /api/checkin. 409 → toast "Seat taken, please choose another." Success → refresh layout. - If
status = locked/closed: RoomCanvas is read-only. Show "Check-in closed" banner. - Poll
GET /api/checkin/:codeevery 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
-
RoomCanvaswithnotesprop — 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(formake 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.yamlandk8s/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:
- Frontend visual design integration from Claude Design
- k8s deployment (
kubectl apply -f k8s/) - First tutor account creation (seed script or direct DB insert)