13 Commits

Author SHA1 Message Date
f97f91781a fix: use debian:trixie-slim runtime to match builder glibc 2.38
Some checks failed
Release / release (push) Failing after 8m35s
rust:1.95-slim is trixie-based (glibc 2.38); bookworm-slim only has
2.36, causing a crash on startup.
2026-05-05 03:36:22 +02:00
99b501d69c fix: ignore RUSTSEC-2023-0071 in release workflow cargo audit
Some checks failed
Release / release (push) Failing after 8m30s
2026-05-05 03:16:50 +02:00
b7d20d9573 chore: ignore screenshot-*.png files
Some checks failed
Release / release (push) Failing after 2m23s
2026-05-05 03:12:23 +02:00
39341ce69d fix: run svelte-kit sync before svelte-check in Docker build
Some checks failed
CI / test (pull_request) Has been cancelled
CI / test (push) Successful in 8m19s
.svelte-kit/tsconfig.json is gitignored and must be generated at
build time — svelte-check cannot run without it.
2026-05-05 02:48:40 +02:00
ab9d1fc547 fix: track Cargo.lock so Docker CI build can copy it
Some checks failed
CI / test (pull_request) Has been cancelled
CI / test (push) Failing after 6m57s
Cargo.lock was in .gitignore, making it absent from the git checkout
that CI builds the Docker image from — COPY backend/Cargo.lock failed.
2026-05-05 02:39:03 +02:00
681b43174b fix: implement random-port discovery for CI E2E backend
Some checks failed
CI / test (pull_request) Has been cancelled
CI / test (push) Failing after 4m51s
When PORT=0, the backend now writes its actual bound port to
data/test/.port. test-env.sh reads that file when TT_TEST_PORT=0
so all targets (test-up, test-reset, test-down) resolve the real URL.
test-up waits for .port to appear before the health-check loop.
2026-05-05 02:24:29 +02:00
24f2556c9d fix: replace per-element CASE WHEN in migration 003 with WHERE EXISTS
Some checks failed
CI / test (push) Failing after 3m32s
CI / test (pull_request) Failing after 3m26s
Instead of applying a per-element heuristic (skip if value ≤ 50), identify
pixel-scale rooms at the row level with WHERE EXISTS, then convert all
elements unconditionally. Eliminates the risk of mixed-scale elements within
the same room.
2026-05-05 02:02:30 +02:00
4939838a7f fix: address PR #2 review findings across backend and frontend
Some checks failed
CI / test (push) Failing after 4m9s
CI / test (pull_request) Failing after 3m26s
- Makefile: add SHELL := /bin/bash so test-env.sh pipefail works in CI
- RoomCanvas: fix onElementClick firing on drag start (now fires on mouseup
  for click-in-place only); fix Props type to accept null; guard grid pattern
  against snapStep=0 (invalid SVG); remove unsafe null cast
- live/[slotId]: fix studentNamesBySeat $derived wrapping a function instead
  of a value — reactivity was broken, map never updated
- s/[code]: block clicks on occupied seats before hitting the backend;
  pass occupiedSeatIds to confirmed-view RoomCanvas; clear errorMsg on retry
- rooms/+page: replace alert() in deleteRoom with inline errorMsg state
- rooms/[roomId]: replace deprecated .substr with .slice
- courses.rs: assign_tutor uses fetch_optional → 404 on unknown tutor_id
  instead of propagating RowNotFound as 500
- rooms.rs: delete_room returns 404 when room does not exist; replace
  fract() != 0.0 float check with epsilon-based validation
- auth_routes.rs: refresh endpoint re-checks is_active so deactivated tutors
  cannot obtain new access tokens; fix test INSERT to include is_active
- tutors.rs: wrap delete_tutor reference checks and DELETE in a transaction
- attendance.rs: replace #[allow(clippy::type_complexity)] with type alias
- migrations/003: document > 50 heuristic precondition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 01:55:35 +02:00
827eb63bab fix: address review findings — error handling, migration safety, CI audit
Some checks failed
CI / test (push) Failing after 3m6s
CI / test (pull_request) Failing after 3m22s
Backend:
- migration 003: apply pixel→grid transform per-element (CASE WHEN > 50)
  instead of per-row, preventing double-conversion of mixed-scale rooms;
  skip empty arrays via json_array_length guard to avoid NULL assignment
- attendance.rs: log layout JSON parse errors instead of silently
  swallowing them with .ok()
- tutors.rs: check rows_affected() in set_tutor_active and return 404
  for non-existent IDs; remap FK constraint errors on delete to 409
  so concurrent inserts between conflict-check and DELETE don't surface
  as 500

Frontend:
- live/[slotId]: expose polling failures to the tutor via error banner
  instead of only console.error
- s/[code]: split checkin into two try/catch blocks so a successful
  POST followed by a failed reload doesn't report failure to the student;
  fix dead '409' string detection to match actual server error 'seat taken'
- rooms/[roomId]: remove duplicate onMount fetch; add .catch() to $effect
- tutors: expose loadTutors failures via error banner, not just console
- rooms: fix bare catch in createRoom (captures error, shows message);
  add try/catch to onMount rooms load

CI:
- sync cargo audit --ignore RUSTSEC-2023-0071 with Makefile; the advisory
  is in rsa which sqlx-mysql retains in the lock file even when the mysql
  feature is disabled — aws_lc_rs correctly removes it from the active tree
2026-05-05 01:28:40 +02:00
3b9c755e39 feat: unified bug fixes, tutor lifecycle, and room editor refactor
Some checks failed
CI / test (push) Failing after 10m30s
CI / test (pull_request) Failing after 7m25s
- Security: Fixed RUSTSEC-2023-0071 via aws_lc_rs
- API: Fixed empty 200 body parsing and check-in typing
- Tutors: Added is_active flag, safe deletion with 409 conflict checks, and admin toggle UI
- Rooms: Migrated room layouts from pixel to grid scale, added additive layout validators
- UI: Improved RoomCanvas with dynamic sizing, interactive editing, snap-to-grid
- App: Replaced static SeatMap component with dynamic RoomCanvas across live and checkin views
2026-05-05 00:47:05 +02:00
d79c7ed08c added git worktrees to the plan 2026-05-04 17:26:24 +02:00
650f3456cb some planning and issue finding 2026-05-04 17:15:53 +02:00
08cb668bab fix: restore login page accessibility and wire silent token refresh
All checks were successful
Release / release (push) Successful in 7m12s
The admin layout guard rendered only a "Redirecting to login..." placeholder
for the /admin/login child route, trapping every unauthenticated visitor.
Exempt the login route from the auth gate so the form renders correctly.

Also wire the new POST /api/auth/refresh endpoint (from the dual-token
migration) into both auth.init() and the api request() 401 handler, so
sessions survive the 15-minute access-token lifetime without a hard logout.

Adds a Playwright regression test asserting the login form is visible
in a clean (no-cookie) browser context.
2026-05-04 04:19:42 +02:00
41 changed files with 4791 additions and 671 deletions

2
.gitignore vendored
View File

@@ -19,7 +19,6 @@ build/
# Rust
target/
Cargo.lock
debug/
release/
@@ -43,3 +42,4 @@ conductor/
docs/review.md
docs/tutortool_audit.md
*.log
screenshot-*.png

View File

@@ -5,11 +5,12 @@ RUN corepack enable && corepack prepare pnpm --activate
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm svelte-kit sync
RUN pnpm run check
RUN pnpm run build
# --- Backend Build ---
FROM rust:1.95-slim AS backend-builder
FROM rust:1.95-slim-bookworm AS backend-builder
WORKDIR /app/backend
COPY backend/Cargo.toml backend/Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
@@ -19,7 +20,7 @@ COPY backend/demo ./demo
RUN touch src/main.rs && cargo build --release
# --- Runtime ---
FROM debian:bookworm-slim
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN useradd -u 1000 -m app
WORKDIR /app

View File

@@ -1,3 +1,5 @@
SHELL := /bin/bash
.PHONY: dev dev-backend dev-frontend build test compose-up seed-demo \
test-up test-down test-reset test-rebuild test-e2e
@@ -58,10 +60,19 @@ test-up:
exit 0; \
fi; \
[ -f "$$TT_TEST_DB" ] || $(MAKE) test-rebuild; \
rm -f data/test/.port; \
DATABASE_URL="sqlite:$$TT_TEST_DB" PORT=$$TT_TEST_PORT TT_TEST_MODE=1 JWT_SECRET=testsecret STATIC_DIR=frontend/build \
cargo run --manifest-path backend/Cargo.toml &>/tmp/tutortool-test.log & \
echo $$! > data/test/.pid; \
echo "[test-up] Backend PID $$(cat data/test/.pid) starting on port $$TT_TEST_PORT..."; \
if [ "$$TT_TEST_PORT" = "0" ]; then \
for i in $$(seq 1 30); do \
[ -f data/test/.port ] && break; \
sleep 1; \
done; \
. scripts/test-env.sh; \
echo "[test-up] Backend bound to port $$TT_TEST_PORT"; \
fi; \
for i in $$(seq 1 30); do \
curl -fs "$$TT_BASE_URL/health" >/dev/null 2>&1 && break; \
sleep 1; \

3047
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
bcrypt = "0.19"
argon2 = "0.5"
tower-http = { version = "0.6", features = ["fs", "cors", "trace", "set-header"] }

View File

@@ -14,30 +14,222 @@ VALUES (1, 1);
-- Rooms
INSERT OR IGNORE INTO rooms (id, name, layout_json)
VALUES (1, 'Room A (Small)', '[
{ "id": "T1", "label": "T1", "x": 90, "y": 150, "width": 200, "height": 70, "type": "table" },
{ "id": "T2", "label": "T2", "x": 470, "y": 150, "width": 200, "height": 70, "type": "table" },
{ "id": "T3", "label": "T3", "x": 90, "y": 320, "width": 200, "height": 70, "type": "table" },
{ "id": "T4", "label": "T4", "x": 470, "y": 320, "width": 200, "height": 70, "type": "table" },
{ "id": "T1-1", "label": "1", "x": 146.0, "y": 128, "width": 36, "height": 36, "type": "seat" },
{ "id": "T1-2", "label": "2", "x": 234.0, "y": 128, "width": 36, "height": 36, "type": "seat" },
{ "id": "T1-3", "label": "3", "x": 146.0, "y": 242, "width": 36, "height": 36, "type": "seat" },
{ "id": "T1-4", "label": "4", "x": 234.0, "y": 242, "width": 36, "height": 36, "type": "seat" },
{ "id": "T1-5", "label": "5", "x": 316, "y": 185.0, "width": 36, "height": 36, "type": "seat" },
{ "id": "T2-1", "label": "1", "x": 526.0, "y": 128, "width": 36, "height": 36, "type": "seat" },
{ "id": "T2-2", "label": "2", "x": 614.0, "y": 128, "width": 36, "height": 36, "type": "seat" },
{ "id": "T2-3", "label": "3", "x": 526.0, "y": 242, "width": 36, "height": 36, "type": "seat" },
{ "id": "T2-4", "label": "4", "x": 614.0, "y": 242, "width": 36, "height": 36, "type": "seat" },
{ "id": "T2-5", "label": "5", "x": 696, "y": 185.0, "width": 36, "height": 36, "type": "seat" },
{ "id": "T3-1", "label": "1", "x": 146.0, "y": 298, "width": 36, "height": 36, "type": "seat" },
{ "id": "T3-2", "label": "2", "x": 234.0, "y": 298, "width": 36, "height": 36, "type": "seat" },
{ "id": "T3-3", "label": "3", "x": 146.0, "y": 412, "width": 36, "height": 36, "type": "seat" },
{ "id": "T3-4", "label": "4", "x": 234.0, "y": 412, "width": 36, "height": 36, "type": "seat" },
{ "id": "T3-5", "label": "5", "x": 316, "y": 355.0, "width": 36, "height": 36, "type": "seat" },
{ "id": "T4-1", "label": "1", "x": 526.0, "y": 298, "width": 36, "height": 36, "type": "seat" },
{ "id": "T4-2", "label": "2", "x": 614.0, "y": 298, "width": 36, "height": 36, "type": "seat" },
{ "id": "T4-3", "label": "3", "x": 526.0, "y": 412, "width": 36, "height": 36, "type": "seat" },
{ "id": "T4-4", "label": "4", "x": 614.0, "y": 412, "width": 36, "height": 36, "type": "seat" },
{ "id": "T4-5", "label": "5", "x": 696, "y": 355.0, "width": 36, "height": 36, "type": "seat" }
{
"id": "T1",
"label": "T1",
"x": 2.25,
"y": 3.75,
"width": 5.0,
"height": 1.75,
"type": "table"
},
{
"id": "T2",
"label": "T2",
"x": 11.75,
"y": 3.75,
"width": 5.0,
"height": 1.75,
"type": "table"
},
{
"id": "T3",
"label": "T3",
"x": 2.25,
"y": 8.0,
"width": 5.0,
"height": 1.75,
"type": "table"
},
{
"id": "T4",
"label": "T4",
"x": 11.75,
"y": 8.0,
"width": 5.0,
"height": 1.75,
"type": "table"
},
{
"id": "T1-1",
"label": "1",
"x": 3.65,
"y": 3.2,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T1-2",
"label": "2",
"x": 5.85,
"y": 3.2,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T1-3",
"label": "3",
"x": 3.65,
"y": 6.05,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T1-4",
"label": "4",
"x": 5.85,
"y": 6.05,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T1-5",
"label": "5",
"x": 7.9,
"y": 4.62,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T2-1",
"label": "1",
"x": 13.15,
"y": 3.2,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T2-2",
"label": "2",
"x": 15.35,
"y": 3.2,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T2-3",
"label": "3",
"x": 13.15,
"y": 6.05,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T2-4",
"label": "4",
"x": 15.35,
"y": 6.05,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T2-5",
"label": "5",
"x": 17.4,
"y": 4.62,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T3-1",
"label": "1",
"x": 3.65,
"y": 7.45,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T3-2",
"label": "2",
"x": 5.85,
"y": 7.45,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T3-3",
"label": "3",
"x": 3.65,
"y": 10.3,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T3-4",
"label": "4",
"x": 5.85,
"y": 10.3,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T3-5",
"label": "5",
"x": 7.9,
"y": 8.88,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T4-1",
"label": "1",
"x": 13.15,
"y": 7.45,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T4-2",
"label": "2",
"x": 15.35,
"y": 7.45,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T4-3",
"label": "3",
"x": 13.15,
"y": 10.3,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T4-4",
"label": "4",
"x": 15.35,
"y": 10.3,
"width": 0.9,
"height": 0.9,
"type": "seat"
},
{
"id": "T4-5",
"label": "5",
"x": 17.4,
"y": 8.88,
"width": 0.9,
"height": 0.9,
"type": "seat"
}
]');
-- Students

View File

@@ -0,0 +1,26 @@
-- Normalize room layout units: divide pixel-scale coordinates by 40.
-- Only runs on rooms that contain at least one element with a coordinate > 50
-- (i.e. still in pixel scale). Once identified, all elements are converted
-- unconditionally — no per-element heuristic needed.
UPDATE rooms
SET layout_json = (
SELECT json_group_array(
json_object(
'id', json_extract(value, '$.id'),
'label', json_extract(value, '$.label'),
'x', ROUND(CAST(json_extract(value, '$.x') AS REAL) / 40.0, 2),
'y', ROUND(CAST(json_extract(value, '$.y') AS REAL) / 40.0, 2),
'width', ROUND(CAST(json_extract(value, '$.width') AS REAL) / 40.0, 2),
'height', ROUND(CAST(json_extract(value, '$.height') AS REAL) / 40.0, 2),
'type', json_extract(value, '$.type')
)
)
FROM json_each(rooms.layout_json)
)
WHERE EXISTS (
SELECT 1 FROM json_each(rooms.layout_json)
WHERE json_extract(value, '$.x') > 50
OR json_extract(value, '$.y') > 50
OR json_extract(value, '$.width') > 50
OR json_extract(value, '$.height') > 50
);

View File

@@ -0,0 +1 @@
ALTER TABLE tutors ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT 1;

View File

@@ -85,7 +85,14 @@ async fn main() {
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("failed to bind");
tracing::info!("listening on {}", addr);
let actual_addr = listener.local_addr().expect("failed to get local addr");
tracing::info!("listening on {}", actual_addr);
// When started with PORT=0 (random), write actual port so the test harness can discover it
if port == "0" {
let _ = std::fs::create_dir_all("data/test");
let _ = std::fs::write("data/test/.port", actual_addr.port().to_string());
}
axum::serve(
listener,

View File

@@ -14,6 +14,7 @@ pub struct Tutor {
pub name: String,
pub email: String,
pub is_superadmin: bool,
pub is_active: bool,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
@@ -48,6 +49,8 @@ pub struct Slot {
pub end_time: String,
pub status: String,
pub code: Option<String>,
#[sqlx(skip)]
pub layout: Option<Vec<LayoutElement>>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
@@ -131,6 +134,10 @@ pub struct AssignTutor {
pub tutor_id: i64,
}
#[derive(Deserialize)]
pub struct SetTutorActive {
pub is_active: bool,
}
#[derive(Deserialize)]
pub struct CheckinRequest {
pub code: String,
pub student_id: i64,

View File

@@ -79,14 +79,52 @@ async fn get_session_attendance(
.fetch_all(&pool)
.await?;
// Get all slots for the session
let slots: Vec<crate::models::Slot> = sqlx::query_as(
"SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code FROM slots WHERE session_id = ?"
type SlotRow = (
i64,
i64,
Option<i64>,
i64,
String,
String,
String,
Option<String>,
Option<String>,
);
let slot_rows: Vec<SlotRow> = sqlx::query_as(
"SELECT s.id, s.session_id, s.room_id, s.tutor_id, s.start_time, s.end_time, s.status, s.code, r.layout_json
FROM slots s
LEFT JOIN rooms r ON s.room_id = r.id
WHERE s.session_id = ?"
)
.bind(session_id)
.fetch_all(&pool)
.await?;
let mut slots = Vec::new();
for row in slot_rows {
let layout: Option<Vec<crate::models::LayoutElement>> = match row.8 {
Some(ref json_str) => match serde_json::from_str(json_str) {
Ok(v) => Some(v),
Err(e) => {
tracing::error!(slot_id = row.0, err = %e, "failed to deserialize room layout_json");
None
}
},
None => None,
};
slots.push(crate::models::Slot {
id: row.0,
session_id: row.1,
room_id: row.2,
tutor_id: row.3,
start_time: row.4,
end_time: row.5,
status: row.6,
code: row.7,
layout,
});
}
// Get all attendances for these slots
let attendances: Vec<crate::models::Attendance> = sqlx::query_as(
"SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)"

View File

@@ -24,14 +24,14 @@ async fn login(
jar: CookieJar,
Json(req): Json<LoginRequest>,
) -> Result<(CookieJar, Json<Value>), AppError> {
let tutor: Option<(i64, String, String, bool)> = sqlx::query_as(
"SELECT id, email, password_hash, is_superadmin FROM tutors WHERE email = ?",
let tutor: Option<(i64, String, String, bool, bool)> = sqlx::query_as(
"SELECT id, email, password_hash, is_superadmin, is_active FROM tutors WHERE email = ?",
)
.bind(&req.email)
.fetch_optional(&state.pool)
.await?;
let (id, _email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
let (id, _email, hash, is_superadmin, is_active) = tutor.ok_or(AppError::Unauthorized)?;
let mut rehash_needed = false;
let mut authed = false;
@@ -57,6 +57,11 @@ async fn login(
return Err(AppError::Unauthorized);
}
// Reject inactive tutors AFTER password verification to avoid timing attacks or info leakage
if !is_active {
return Err(AppError::Unauthorized);
}
// Lazy rehash to Argon2 if we used bcrypt
if rehash_needed {
let salt = SaltString::generate(&mut rand::thread_rng());
@@ -106,6 +111,15 @@ async fn refresh(
let claims = auth::decode_jwt(&refresh_token, &state.jwt_secret, true)?;
// Re-check is_active so deactivated tutors cannot refresh their session
let is_active: Option<bool> = sqlx::query_scalar("SELECT is_active FROM tutors WHERE id = ?")
.bind(claims.sub)
.fetch_optional(&state.pool)
.await?;
if !is_active.unwrap_or(false) {
return Err(AppError::Unauthorized);
}
// Issue new access token
let access_token =
auth::encode_jwt(claims.sub, claims.is_superadmin, &state.jwt_secret, false)?;
@@ -172,10 +186,11 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn login_returns_superadmin_and_cookies(pool: sqlx::SqlitePool) {
let hash = bcrypt::hash("secret", 4).unwrap();
sqlx::query("INSERT INTO tutors (name,email,password_hash) VALUES (?,?,?)")
sqlx::query("INSERT INTO tutors (name,email,password_hash,is_active) VALUES (?,?,?,?)")
.bind("Test")
.bind("t@test.com")
.bind(&hash)
.bind(true)
.execute(&pool)
.await
.unwrap();

View File

@@ -65,6 +65,20 @@ async fn assign_tutor(
if !claims.is_superadmin {
return Err(AppError::Unauthorized);
}
// Verify tutor exists and is active
let maybe_active: Option<bool> =
sqlx::query_scalar("SELECT is_active FROM tutors WHERE id = ?")
.bind(req.tutor_id)
.fetch_optional(&pool)
.await?;
let is_active = maybe_active.ok_or(AppError::NotFound)?;
if !is_active {
return Err(AppError::BadRequest(
"Tutor:in ist inaktiv und kann nicht zugewiesen werden.".into(),
));
}
sqlx::query("INSERT OR IGNORE INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
.bind(req.tutor_id)
.bind(course_id)
@@ -101,7 +115,7 @@ async fn list_assigned_tutors(
}
let tutors = sqlx::query_as::<_, crate::models::Tutor>(
"SELECT t.id, t.name, t.email, t.is_superadmin FROM tutors t
"SELECT t.id, t.name, t.email, t.is_superadmin, t.is_active FROM tutors t
JOIN tutor_courses tc ON tc.tutor_id = t.id
WHERE tc.course_id = ?",
)

View File

@@ -51,6 +51,23 @@ fn validate_layout(layout: &[LayoutElement]) -> Result<(), AppError> {
"x, y, width, height must all be >= 0.0".into(),
));
}
// MAX_CANVAS = 100 bound
if elem.x + elem.width > 100.0 || elem.y + elem.height > 100.0 {
return Err(AppError::BadRequest(
"element outside of 100x100 canvas".into(),
));
}
// 0.5-step multiple check — use epsilon comparison to avoid IEEE-754 fract() edge cases
let not_half_step = |v: f64| ((v * 2.0).round() - v * 2.0).abs() > f64::EPSILON;
if not_half_step(elem.x)
|| not_half_step(elem.y)
|| not_half_step(elem.width)
|| not_half_step(elem.height)
{
return Err(AppError::BadRequest(
"coords/dims must be multiples of 0.5".into(),
));
}
// seat labels must be unique among seats
if elem.kind == "seat" {
if elem.label.is_empty() {
@@ -143,10 +160,39 @@ async fn update_room_layout(
Ok(Json(json!({"id": id})))
}
async fn delete_room(
_claims: TutorClaims,
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> Result<StatusCode, AppError> {
// Check if room is attached to any slots
let slot_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM slots WHERE room_id = ?")
.bind(id)
.fetch_one(&pool)
.await?;
if slot_count > 0 {
return Err(AppError::Conflict(format!(
"Raum hat noch {} Slot(s). Bitte zuerst entfernen.",
slot_count
)));
}
let result = sqlx::query("DELETE FROM rooms WHERE id = ?")
.bind(id)
.execute(&pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/rooms", get(list_rooms).post(create_room))
.route("/api/admin/rooms/{id}", get(get_room))
.route("/api/admin/rooms/{id}", get(get_room).delete(delete_room))
.route("/api/admin/rooms/{id}/layout", put(update_room_layout))
}
@@ -321,6 +367,89 @@ mod tests {
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[sqlx::test(migrations = "./migrations")]
async fn layout_validation_rejects_out_of_bounds(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool).await;
let layout = json!([
{"id":"s1","label":"A1","x":99.0,"y":0.0,"width":2.0,"height":1.0,"type":"seat"}
]);
let (status, _) = post_json(
app,
"/api/admin/rooms",
&auth,
json!({"name":"Bad","layout":layout}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[sqlx::test(migrations = "./migrations")]
async fn layout_validation_rejects_non_step(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool).await;
let layout = json!([
{"id":"s1","label":"A1","x":0.1,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}
]);
let (status, _) = post_json(
app,
"/api/admin/rooms",
&auth,
json!({"name":"Bad","layout":layout}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_room_with_no_slots_succeeds(pool: sqlx::SqlitePool) {
use crate::test_helpers::delete;
let (app, auth) = build_test_app(pool.clone()).await;
// create room
let (_, body) = post_json(
app.clone(),
"/api/admin/rooms",
&auth,
json!({"name":"R1","layout":[{"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}]}),
)
.await;
let id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
.as_i64()
.unwrap();
let (status, _) = delete(app, &format!("/api/admin/rooms/{id}"), &auth).await;
assert_eq!(status, StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_room_with_slot_returns_409(pool: sqlx::SqlitePool) {
use crate::test_helpers::delete;
let (app, auth) = build_test_app(pool.clone()).await;
// create room
let (_, body) = post_json(
app.clone(),
"/api/admin/rooms",
&auth,
json!({"name":"R1","layout":[{"id":"s1","label":"A1","x":0.0,"y":0.0,"width":1.0,"height":1.0,"type":"seat"}]}),
)
.await;
let room_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
.as_i64()
.unwrap();
let course_id: i64 = sqlx::query_scalar(
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
)
.fetch_one(&pool)
.await
.unwrap();
let session_id: i64 = sqlx::query_scalar("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-05-04') RETURNING id").bind(course_id).fetch_one(&pool).await.unwrap();
sqlx::query("INSERT INTO slots (session_id, room_id, tutor_id, start_time, end_time, status) VALUES (?, ?, 1, '09:00', '10:00', 'open')")
.bind(session_id).bind(room_id).execute(&pool).await.unwrap();
let (status, body) = delete(app, &format!("/api/admin/rooms/{room_id}"), &auth).await;
assert_eq!(status, StatusCode::CONFLICT);
assert!(String::from_utf8_lossy(&body).contains("Slot"));
}
#[sqlx::test(migrations = "./migrations")]
async fn get_room_not_found(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool).await;

View File

@@ -108,16 +108,19 @@ async fn create_slot(
// Verify requesting tutor has access to the course
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
// Verify the slot's tutor_id belongs to this course
let member: Option<(i64,)> =
sqlx::query_as("SELECT 1 FROM tutor_courses WHERE tutor_id = ? AND course_id = ?")
.bind(req.tutor_id)
.bind(course_id)
.fetch_optional(&pool)
.await?;
// Verify the slot's tutor_id belongs to this course AND is active
let member: Option<(i64,)> = sqlx::query_as(
"SELECT 1 FROM tutor_courses tc
JOIN tutors t ON t.id = tc.tutor_id
WHERE tc.tutor_id = ? AND tc.course_id = ? AND t.is_active = 1",
)
.bind(req.tutor_id)
.bind(course_id)
.fetch_optional(&pool)
.await?;
if member.is_none() {
return Err(AppError::BadRequest(
"tutor_id is not a member of this course".into(),
"Tutor:in ist in diesem Kurs nicht aktiv oder existiert nicht.".into(),
));
}

View File

@@ -2,13 +2,13 @@ use crate::{
AppState,
auth::TutorClaims,
error::AppError,
models::{CreateTutor, Tutor},
models::{CreateTutor, SetTutorActive, Tutor},
};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{delete, get},
routing::{delete, get, patch},
};
use sqlx::SqlitePool;
@@ -21,7 +21,7 @@ async fn list_tutors(
}
let tutors = sqlx::query_as::<_, Tutor>(
"SELECT id, name, email, is_superadmin FROM tutors ORDER BY name",
"SELECT id, name, email, is_superadmin, is_active FROM tutors ORDER BY name",
)
.fetch_all(&pool)
.await?;
@@ -50,7 +50,7 @@ async fn create_tutor(
.to_string();
let id = sqlx::query(
"INSERT INTO tutors (name, email, password_hash, is_superadmin) VALUES (?, ?, ?, ?)",
"INSERT INTO tutors (name, email, password_hash, is_superadmin, is_active) VALUES (?, ?, ?, ?, 1)",
)
.bind(&req.name)
.bind(&req.email)
@@ -67,10 +67,39 @@ async fn create_tutor(
name: req.name,
email: req.email,
is_superadmin: req.is_superadmin,
is_active: true,
}),
))
}
async fn set_tutor_active(
claims: TutorClaims,
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
Json(req): Json<SetTutorActive>,
) -> Result<StatusCode, AppError> {
if !claims.is_superadmin {
return Err(AppError::Unauthorized);
}
// Don't allow deactivating yourself
if claims.sub == id && !req.is_active {
return Err(AppError::Conflict("cannot deactivate yourself".into()));
}
let result = sqlx::query("UPDATE tutors SET is_active = ? WHERE id = ?")
.bind(req.is_active)
.bind(id)
.execute(&pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
async fn delete_tutor(
claims: TutorClaims,
State(pool): State<SqlitePool>,
@@ -85,16 +114,243 @@ async fn delete_tutor(
return Err(AppError::Conflict("cannot delete yourself".into()));
}
sqlx::query("DELETE FROM tutors WHERE id = ?")
.bind(id)
.execute(&pool)
.await?;
// Wrap reference checks and DELETE in one transaction to avoid TOCTOU
let mut tx = pool.begin().await?;
let course_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tutor_courses WHERE tutor_id = ?")
.bind(id)
.fetch_one(&mut *tx)
.await?;
if course_count > 0 {
return Err(AppError::Conflict(format!(
"Tutor:in hat noch {} Kurszuordnung(en). Bitte zuerst entfernen oder deaktivieren.",
course_count
)));
}
let slot_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM slots WHERE tutor_id = ?")
.bind(id)
.fetch_one(&mut *tx)
.await?;
if slot_count > 0 {
return Err(AppError::Conflict(format!(
"Tutor:in hat noch {} Slot(s). Bitte zuerst entfernen oder deaktivieren.",
slot_count
)));
}
let note_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notes WHERE tutor_id = ?")
.bind(id)
.fetch_one(&mut *tx)
.await?;
if note_count > 0 {
return Err(AppError::Conflict(format!(
"Tutor:in hat noch {} Notiz(en). Bitte zuerst entfernen oder deaktivieren.",
note_count
)));
}
match sqlx::query("DELETE FROM tutors WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await
{
Ok(_) => {}
Err(sqlx::Error::Database(e)) if e.message().contains("FOREIGN KEY") => {
return Err(AppError::Conflict(
"Tutor:in hat noch Verweise in der Datenbank.".into(),
));
}
Err(e) => return Err(AppError::Db(e)),
}
tx.commit().await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/admin/tutors", get(list_tutors).post(create_tutor))
.route("/api/admin/tutors/{id}", delete(delete_tutor))
.route("/api/admin/tutors/{id}/active", patch(set_tutor_active))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_admin_app, delete, patch_json};
use argon2::PasswordHasher;
use serde_json::json;
#[sqlx::test(migrations = "./migrations")]
async fn delete_tutor_with_no_refs_succeeds(pool: SqlitePool) {
let (app, auth) = build_test_admin_app(pool.clone()).await;
// Create a tutor to delete
let hash = bcrypt::hash("test", 4).unwrap();
let id: i64 = sqlx::query_scalar("INSERT INTO tutors (name, email, password_hash, is_superadmin, is_active) VALUES ('DeleteMe', 'del@test.com', ?, 0, 1) RETURNING id")
.bind(hash)
.fetch_one(&pool)
.await.unwrap();
let status = delete(app, &format!("/api/admin/tutors/{}", id), &auth)
.await
.0;
assert_eq!(status, StatusCode::NO_CONTENT);
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tutors WHERE id = ?)")
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
assert!(!exists);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_tutor_with_course_assignment_returns_409(pool: SqlitePool) {
let (app, auth) = build_test_admin_app(pool.clone()).await;
let id: i64 = sqlx::query_scalar("INSERT INTO tutors (name, email, password_hash, is_superadmin, is_active) VALUES ('CourseTutor', 'course@test.com', 'hash', 0, 1) RETURNING id")
.fetch_one(&pool).await.unwrap();
let course_id: i64 = sqlx::query_scalar(
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
)
.fetch_one(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
.bind(id)
.bind(course_id)
.execute(&pool)
.await
.unwrap();
let (status, body) = delete(app, &format!("/api/admin/tutors/{}", id), &auth).await;
assert_eq!(status, StatusCode::CONFLICT);
assert!(String::from_utf8_lossy(&body).contains("Kurszuordnung"));
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_tutor_with_slot_returns_409(pool: SqlitePool) {
let (app, auth) = build_test_admin_app(pool.clone()).await;
let id: i64 = sqlx::query_scalar("INSERT INTO tutors (name, email, password_hash, is_superadmin, is_active) VALUES ('SlotTutor', 'slot@test.com', 'hash', 0, 1) RETURNING id")
.fetch_one(&pool).await.unwrap();
let course_id: i64 = sqlx::query_scalar(
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
)
.fetch_one(&pool)
.await
.unwrap();
let session_id: i64 = sqlx::query_scalar("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-05-04') RETURNING id")
.bind(course_id).fetch_one(&pool).await.unwrap();
sqlx::query("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'closed')")
.bind(session_id).bind(id).execute(&pool).await.unwrap();
let (status, body) = delete(app, &format!("/api/admin/tutors/{}", id), &auth).await;
assert_eq!(status, StatusCode::CONFLICT);
assert!(String::from_utf8_lossy(&body).contains("Slot"));
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_tutor_with_note_returns_409(pool: SqlitePool) {
let (app, auth) = build_test_admin_app(pool.clone()).await;
let id: i64 = sqlx::query_scalar("INSERT INTO tutors (name, email, password_hash, is_superadmin, is_active) VALUES ('NoteTutor', 'note@test.com', 'hash', 0, 1) RETURNING id")
.fetch_one(&pool).await.unwrap();
let course_id: i64 = sqlx::query_scalar(
"INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id",
)
.fetch_one(&pool)
.await
.unwrap();
let student_id: i64 = sqlx::query_scalar(
"INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id",
)
.bind(course_id)
.fetch_one(&pool)
.await
.unwrap();
let session_id: i64 = sqlx::query_scalar("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-05-04') RETURNING id")
.bind(course_id).fetch_one(&pool).await.unwrap();
let slot_id: i64 = sqlx::query_scalar("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'closed') RETURNING id")
.bind(session_id).bind(1).fetch_one(&pool).await.unwrap(); // Bind to admin
sqlx::query("INSERT INTO notes (slot_id, student_id, tutor_id, content, updated_at) VALUES (?, ?, ?, 'test', '2026-05-04T10:00:00Z')")
.bind(slot_id).bind(student_id).bind(id).execute(&pool).await.unwrap();
let (status, body) = delete(app, &format!("/api/admin/tutors/{}", id), &auth).await;
assert_eq!(status, StatusCode::CONFLICT);
assert!(String::from_utf8_lossy(&body).contains("Notiz"));
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_self_returns_409(pool: SqlitePool) {
let (app, auth) = build_test_admin_app(pool.clone()).await;
let admin_id: i64 =
sqlx::query_scalar("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(&pool)
.await
.unwrap();
let (status, body) = delete(app, &format!("/api/admin/tutors/{}", admin_id), &auth).await;
assert_eq!(status, StatusCode::CONFLICT);
assert!(String::from_utf8_lossy(&body).contains("cannot delete yourself"));
}
#[sqlx::test(migrations = "./migrations")]
async fn cannot_deactivate_self_returns_409(pool: SqlitePool) {
let (app, auth) = build_test_admin_app(pool.clone()).await;
let admin_id: i64 =
sqlx::query_scalar("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(&pool)
.await
.unwrap();
let (status, body) = patch_json(
app,
&format!("/api/admin/tutors/{}/active", admin_id),
&auth,
json!({"is_active": false}),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert!(String::from_utf8_lossy(&body).contains("cannot deactivate yourself"));
}
#[sqlx::test(migrations = "./migrations")]
async fn set_active_false_then_login_fails(pool: SqlitePool) {
let (app, auth) = build_test_admin_app(pool.clone()).await;
let hash = argon2::Argon2::default()
.hash_password(
"pass".as_bytes(),
&argon2::password_hash::SaltString::generate(&mut rand::thread_rng()),
)
.unwrap()
.to_string();
let id: i64 = sqlx::query_scalar("INSERT INTO tutors (name, email, password_hash, is_superadmin, is_active) VALUES ('LoginFail', 'fail@test.com', ?, 0, 1) RETURNING id")
.bind(hash).fetch_one(&pool).await.unwrap();
// Deactivate
let status = patch_json(
app.clone(),
&format!("/api/admin/tutors/{}/active", id),
&auth,
json!({"is_active": false}),
)
.await
.0;
assert_eq!(status, StatusCode::NO_CONTENT);
// Attempt login
let (status, _) = crate::test_helpers::post_json(
app,
"/api/auth/login",
"",
json!({"email": "fail@test.com", "password": "pass"}),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
}

View File

@@ -17,7 +17,7 @@ pub async fn make_token(
) -> String {
let hash = bcrypt::hash("testpass", 4).unwrap();
sqlx::query(
"INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin) VALUES (?,?,?,?)",
"INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin,is_active) VALUES (?,?,?,?,1)",
)
.bind("Test Tutor")
.bind(email)

View File

@@ -1,161 +0,0 @@
# Implementation Plan: Attendance Tracking Tool (Continuation)
**Objective:** Complete the backend API, scaffold the SvelteKit frontend, implement all frontend views based on the provided design handoff, and set up deployment manifests. This plan picks up from where the `.worktrees/feature-tutortool` workspace left off (Tasks 1-8 completed).
**Scope:**
- Complete backend APIs for attendance, notes, and exports.
- Serve SvelteKit SPA fallback via Axum.
- Scaffold SvelteKit frontend.
- Implement UI pages for Admin tools (Dashboard, Courses, Rooms, Sessions, Attendance, Notes, Export) and Student Check-in.
- Configure local development (Makefile, Docker Compose) and K8s manifests.
---
## 1. Backend Completion
### Task 9: Admin Attendance & Notes APIs
**Files to Create/Modify:**
- `backend/src/routes/attendance.rs`
- `backend/src/routes/notes.rs`
**Implementation Steps:**
- Add `POST /api/admin/slots/:id/attendance` (manual entry) and `DELETE /api/admin/slots/:slot_id/attendance/:student_id`.
- Add `GET /api/admin/sessions/:id/attendance` (per-week matrix) and `GET /api/admin/students/:id/attendance`.
- Add `PUT /api/admin/slots/:slot_id/notes/:student_id` (upsert note).
- Add `GET /api/admin/slots/:slot_id/notes` and `GET /api/admin/students/:id/notes`.
- Write corresponding unit tests.
### Task 10: Export API
**Files to Create/Modify:**
- `backend/src/routes/export.rs`
**Implementation Steps:**
- Add `GET /api/admin/export/session/:id/csv` and `/md` (merged per-session weekly attendance).
- Add `GET /api/admin/export/course/:id/csv` and `/md` (full course matrix with Bonus points calculation: +3 if unexcused absences <= 1).
- Add `GET /api/admin/backup` (using `VACUUM INTO '/tmp/backup-<timestamp>.sqlite'` then streaming as `application/octet-stream`).
- Ensure all endpoints verify `TutorClaims` and course access.
### Task 11: Static File Serving & Route Assembly
**Files to Modify:**
- `backend/src/main.rs`
- `backend/src/routes/mod.rs`
**Implementation Steps:**
- Merge `attendance`, `notes`, and `export` routers in `routes/mod.rs`.
- Configure `tower_http::services::ServeDir` in `main.rs` to serve the SvelteKit static build.
- Set up `ServeFile::new(format!("{static_dir}/index.html"))` as the SPA fallback service.
---
## 2. Frontend Development
### Task 12: Scaffold SvelteKit Frontend
**Files to Create/Modify:**
- `frontend/package.json`, `svelte.config.js`, `vite.config.ts`, `src/app.html`, `src/lib/types.ts`, `src/lib/api.ts`, `src/lib/auth.ts`
**Implementation Steps:**
- Initialize SvelteKit with `@sveltejs/adapter-static` configured for SPA fallback.
- Setup Vite proxy `/api` -> `http://localhost:3000`.
- Create TypeScript types mirroring the backend database models.
- Implement API client fetch wrapper (`api.ts`).
- Set up Svelte store for JWT auth (`auth.ts`).
### Task 13: Login Page & Admin Auth Guard
**Files to Create/Modify:**
- `frontend/src/routes/login/+page.svelte`
- `frontend/src/routes/admin/+layout.svelte`
**Implementation Steps:**
- Implement the tutor login form and API integration.
- Protect `/admin` routes using an `onMount` check redirecting unauthenticated users to `/login`.
### Task 14: Dashboard & Slot Management
**Files to Create/Modify:**
- `frontend/src/routes/admin/+page.svelte`
**Implementation Steps:**
- Display all sessions/slots.
- Implement toggles for slot status (`closed`, `open`, `locked`).
- Display check-in link and copy button when a slot is `open`.
### Task 15: Courses & Students UI
**Files to Create/Modify:**
- `frontend/src/routes/admin/courses/+page.svelte`
**Implementation Steps:**
- List courses and forms to create new courses.
- Per-course student management: list students, add individual student, import from CSV, and delete students.
### Task 16: Room Layout Editor
**Files to Create/Modify:**
- `frontend/src/lib/RoomCanvas.svelte`
- `frontend/src/routes/admin/rooms/+page.svelte`
**Implementation Steps:**
- Implement SVG-based `RoomCanvas.svelte` supporting interactive (draggable/editable) mode, student check-in mode, and tutor notes mode.
- Build room management UI: list rooms, create rooms, and edit JSON room layouts visually.
### Task 17: Sessions & Slots UI
**Files to Create/Modify:**
- `frontend/src/routes/admin/sessions/+page.svelte`
**Implementation Steps:**
- Form to create sessions (course, week_nr, date).
- Form to add slots within a session (room, tutor, start_time, end_time).
### Task 18: Student Check-in Page
**Files to Create/Modify:**
- `frontend/src/routes/s/[code]/+page.svelte`
**Implementation Steps:**
- Fetch slot info. If no identity cookie exists, show name dropdown filtered to the course.
- Display `RoomCanvas` indicating free/occupied seats.
- Handle FCFS seat locking API calls (`POST /api/checkin`) and update UI.
- Support read-only mode for locked/closed slots.
### Task 19: Attendance & Notes UI
**Files to Create/Modify:**
- `frontend/src/routes/admin/attendance/+page.svelte`
- `frontend/src/routes/admin/notes/+page.svelte`
**Implementation Steps:**
- Build matrix tables (per-week and per-student) for manual attendance marking/removal.
- Build notes UI utilizing `RoomCanvas` for clicking on seats to leave inline text notes.
### Task 20: Export UI
**Files to Create/Modify:**
- `frontend/src/routes/admin/export/+page.svelte`
**Implementation Steps:**
- Provide buttons to download weekly CSV/Markdown, full course matrix, and SQLite backup directly from the admin interface.
---
## 3. DevOps & Deployment
### Task 21: Local Dev Environment
**Files to Create/Modify:**
- `Makefile`
- `docker-compose.yml`
**Implementation Steps:**
- Write `Makefile` with commands: `dev`, `dev-backend`, `dev-frontend`, `test`, `build`, `compose-up`.
- Create `docker-compose.yml` for testing the production image locally using SQLite mounted via volume.
### Task 22: Dockerfile & K8s Manifests
**Files to Create/Modify:**
- `Dockerfile`
- `k8s/deployment.yaml`, `k8s/service.yaml`, `k8s/ingress.yaml`, `k8s/pvc.yaml`, `k8s/cronjob.yaml`
**Implementation Steps:**
- Write a 3-stage `Dockerfile` (frontend build, backend build, alpine + sqlite runtime).
- Write `pvc.yaml` for SQLite persistent storage.
- Write `deployment.yaml`, `service.yaml`, and `ingress.yaml` (`tutor.puchstein.dev`).
- Write `cronjob.yaml` running at 3 AM daily, executing `sqlite3 /data/attendance.db "VACUUM INTO '/data/backup-$(date +%F).sqlite'"` and pruning files older than 7 days.
---
## Verification Strategy
1. **Unit Tests:** Execute `cargo test` in `backend/` to verify all new endpoints (Tasks 9-11).
2. **End-to-End Test:** Start `make dev` and manually verify all critical paths: tutor login, session/slot creation, student check-in with cookie persistence, FCFS seat collision handling, manual attendance, and exporting.
3. **Deployment Test:** Run `make compose-up` to ensure the built Docker container operates as expected, serving Svelte SPA fallback via Axum properly.

View File

@@ -1,39 +0,0 @@
# Demo Preparation & Seed Data Plan
## Objective
Create an isolated, reproducible demo environment for presenting TutorTool on a Surface Pro 5 (Arch Linux) using Docker. This includes a robust set of seed data to simulate a live application state, which can also be utilized for local end-to-end testing.
## Key Files & Context
- **Environment**: `docker-compose.yml` (existing)
- **Database**: SQLite (`data/attendance.db`)
- **New Files**:
- `backend/migrations/demo_seed.sql`: A standalone SQL script containing isolated test data.
- **Modified Files**:
- `Makefile`: Update to include a `seed-demo` target for easy execution.
## Implementation Steps
### 1. Workspace Isolation via Git Worktree
When implementing this plan, the Gemini CLI will automatically utilize its Git worktree feature to spawn a new isolated workspace (e.g., `feature/demo-seed`). This ensures the backend tooling modifications do not interfere with the `frontend-design-overhaul` worktree or the main branch.
### 2. Create the Seed Data Script (`backend/migrations/demo_seed.sql`)
Create a SQL script that safely injects realistic demo data. It will use `INSERT OR IGNORE` or handle conflicts to ensure it can be run cleanly for both demo and testing purposes.
- **Admin/Tutor Account**:
- Name: "Demo Admin"
- Email: `admin@tutortool.com`
- Password Hash: A pre-calculated bcrypt hash for the password `admin`.
- **Course**: "Demo Course 101" (Semester: "Current").
- **Room Layout**: A valid JSON SVG layout representing a small classroom with a few tables and seats.
- **Students**: Generate ~10 distinct student names linked to the course.
- **Session & Slot**: Create a session for the current date and an "open" slot linked to the demo room, ensuring the check-in feature can be demonstrated immediately without setup.
### 3. Update Makefile
Add a `seed-demo` target to the existing `Makefile`.
- The target will execute the SQLite CLI to run `demo_seed.sql` against the local development database defined by the `DATABASE_URL` environment variable (defaulting to `sqlite:./dev.db` for local dev).
### 4. Demo Run Guide
Provide a short set of instructions on how to start the environment using `docker-compose up` and the new seed command on the Surface Pro 5.
## Verification & Testing
- **Execution**: Run `make seed-demo` against a fresh SQLite database to ensure no foreign key or syntax errors occur.
- **Authentication**: Verify that logging in with `admin@tutortool.com` / `admin` succeeds against the seeded database.

View File

@@ -1,13 +0,0 @@
# Objective
Fix the Playwright MCP configuration in Gemini CLI by aligning it with the working Claude Code configuration.
# Key Files & Context
- `.gemini/settings.json`: This file currently contains the incorrect configuration pointing to `/opt/google/chrome/chrome`.
# Implementation Steps
1. Update `.gemini/settings.json` to modify the `args` array for the `playwright` mcpServer.
2. Remove the `--browser` and `chrome` arguments.
3. Update `--executable-path` to point to `/home/mpuchstein/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome` (the working binary used by Claude).
# Verification & Testing
After updating the configuration, the user will need to restart the Gemini CLI session to apply the changes and verify that the Playwright MCP tools function correctly.

View File

@@ -1,69 +1,90 @@
# Implementation Plan: Room Editor Refactor (Core & Logic)
**Objective:** Standardize the room layout data model, align backend/frontend types, and refactor the core editor logic for robustness and grid-based precision.
**Objective:** Fix the pixel vs. grid-unit mismatch in stored room data, and robustify the editor for professional room planning.
**Background:**
The current room implementation suffers from naming inconsistencies (`type` vs `kind`) and coordinate system mismatches (pixels vs grid units). The editor logic in `RoomCanvas.svelte` is basic and needs to be more robust to support professional room planning.
**Background:**
The editor (`RoomCanvas.svelte`) already stores and renders in grid units (1 unit = 40 px). However, the demo seed (`demo_seed.sql`) was written with raw pixel values (e.g. `width: 200`), causing demo Room A to render broken (200 grid units = 8000 px). Any room created via the editor since launch is correct; any room predating the editor's grid-unit switch (or the demo room) is broken. A one-time data migration is the first priority.
**Note on the `type`/`kind` field:** `backend/src/models.rs:81` already bridges this with `#[serde(rename = "type")] pub kind: String`. The wire format is `type` and `frontend/src/lib/types.ts:33` already uses `type`. **No rename is needed.** If the Rust internal name is ever changed to `type`, a raw identifier (`r#type`) is required since `type` is reserved.
**Note on backend validation:** `backend/src/routes/rooms.rs:1869` already implements `validate_layout` with empty-layout check, unique IDs, allowed types (`seat`, `table`, `gap`, `door`), unique seat labels, and non-negative geometry. Tests at lines 184322 cover all of it. **Task 2 below replaces the previously planned duplicate work.**
**Note on `SeatMap.svelte`:** This plan does **not** touch `SeatMap.svelte`. Its retirement and replacement with a dynamic renderer is handled by the sibling visualization plan. Any `LayoutElement` contract change made here must be cross-checked against `backend/src/routes/checkin.rs:53,194` (which deserialises it) and `frontend/src/lib/types.ts:86` (`CheckinInfo.layout`).
---
## 1. Data Model & Type Alignment
## 1. Data Migration & Seed Fix
### Task 1: Standardize LayoutElement Naming
### Task 1: Pixel → Grid-Unit Migration
**Files to Modify:**
- `backend/src/models.rs`
- `frontend/src/lib/types.ts`
- `backend/migrations/003_normalize_room_layout_units.sql` *(create)*
- `backend/demo/demo_seed.sql`
- `backend/src/routes/rooms.rs` (update tests that assert large numeric coordinates)
**Changes:**
- Unified field name `type` (using `#[serde(rename = "type")]` if necessary in Rust, or changing it consistently).
- Standardize coordinate units: All `x`, `y`, `width`, `height` values in the database will represent **grid units** (e.g., 1 unit = 40px) rather than raw pixels.
- Update `demo_seed.sql` to use these normalized grid units.
- Write `003_normalize_room_layout_units.sql`. For each row in `rooms`, parse `layout_json`; if any element has `x`, `y`, `width`, or `height` > 50, divide all four by 40 and update the row. This heuristic is safe because grid-unit values are small integers/half-steps (max ~30), while pixel values are large (typically 80800).
- Update `demo_seed.sql:1641` to use grid units (e.g. `width: 200``width: 5`). The 24 elements in demo Room A need to be re-measured in grid units.
- Update any integration tests in `rooms.rs` that rely on large pixel-scale layout values.
### Task 2: Backend Validation
### Task 2: Backend Validation (Scope Reduction)
**Files to Modify:**
- `backend/src/routes/rooms.rs`
**Changes:**
- Add validation logic to `POST /api/admin/rooms` and `PUT /api/admin/rooms/:id/layout`.
- Ensure all elements have unique IDs.
- Validate that `type` is one of the allowed strings (`seat`, `table`, `door`, `gap`).
**Changes (additive only — do not duplicate existing logic):**
- Add upper-bound validation: `x` and `y` must be < a `MAX_CANVAS` constant (e.g. 100 grid units). Reject elements that fall off the canvas.
- Add grid-step validation: `x`, `y`, `width`, `height` must be multiples of 0.5 (i.e. `(value * 2) % 1 == 0`). Apply post-migration so existing data has already been normalised.
- Add a test for each new validator.
---
## 2. Editor Core Refactor
### Task 3: RoomCanvas Logic Overhaul
### Task 3: RoomCanvas State & Behaviour
**Files to Modify:**
- `frontend/src/lib/RoomCanvas.svelte`
**Current state (188 lines):**
- Drag: `draggingId / startX / startY` only (lines 2628). No resize state or handles exist.
- Snap: lines 4748 snap to 0.25 grid units (`Math.round(.../10)*10/40`). This is partially correct but the increment should be configurable (0.5 default).
- Rendering: already multiplies by `GRID_SIZE = 40` (line 85). Unit separation is mostly correct.
- Bug: `onmousemove` / `onmouseup` are bound on the SVG only (lines 6971). Releasing the cursor outside the SVG strands the drag. Move these listeners to `window` for the duration of a drag.
**Changes:**
- **Grid Snap:** Implement mandatory snap-to-grid (0.5 or 1.0 unit increments) during dragging and resizing.
- **State Management:** Refactor internal dragging state to be cleaner and more predictable.
- **Selection:** Improve the selection highlight and event propagation.
- **Unit Separation:** Ensure the component strictly thinks in grid units, with the rendering layer handling the pixel scaling.
- **Build resize from scratch.** Add resize handles (e.g. bottom-right corner hit area) per element. Track `resizingId`, `resizeStartX`, `resizeStartY`, `resizeStartW`, `resizeStartH` as drag state. Snap resize delta to 0.5 increments.
- **Fix drag escape.** Bind `mousemove`/`mouseup` to `window` when dragging begins; remove them on drop.
- **Snap increment.** Change snap to 0.5 grid units (from 0.25). Accept an optional `snapStep` prop (default `0.5`) for the snap-toggle feature below.
### Task 4: Editor UI Improvements
**Files to Modify:**
- `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`
**Changes:**
- Add a "Snap to Grid" toggle.
- Add numeric inputs for precise coordinate editing (X, Y, W, H).
- Implement "duplicate element" functionality.
- Better error handling and visual feedback during saving.
**What already exists (do not re-add):**
- Width/height inputs with `step="0.5"` (lines 9097)
- Label input (line 87)
- Add seat/table/door buttons (lines 6466)
- Delete button (line 101)
**What to add:**
- **X/Y numeric inputs** (with `step="0.5"`) for precise coordinate editing of the selected element, bound to its `x` and `y` fields.
- **"+ Gap" button** alongside the existing add buttons. `gap` is accepted by `validate_layout` but is currently unreachable from the UI.
- **"Snap to Grid" toggle.** Bind to a boolean state; pass as `snapStep={snapEnabled ? 0.5 : 0}` to `RoomCanvas`.
- **"Duplicate element" button.** Copies the selected element with a new UUID and offsets it by 1 grid unit.
- **Surface save errors.** `saveLayout` (lines 2729) currently only `console.error`. Display an inline error message in the UI.
---
## 3. Verification
### Automated Tests:
- `backend/src/routes/rooms.rs`: Add unit tests for layout validation.
- `frontend/tests/rooms.spec.ts`: Create a new Playwright test for room editing (creating elements, dragging, snapping, and saving).
- `backend/src/routes/rooms.rs`: Tests for the new upper-bound and grid-step validators.
- `backend/migrations/`: Verify migration 003 runs cleanly on the test DB (use `sqlx migrate run`).
- `frontend/tests/rooms.spec.ts` *(new)*: Playwright test — create a room, add table and two seats via the UI, drag a seat (verify snap), save and reload, assert coordinates are preserved.
### Manual Verification:
1. Create a new room.
2. Add a table and two seats.
3. Verify that dragging snaps to the grid.
4. Save and reload to ensure coordinates are preserved exactly.
5. Inspect the SQLite database to confirm coordinates are stored as small grid units (e.g., `2.5`) instead of large pixel values.
1. `make seed-demo` — reseed with the fixed `demo_seed.sql`.
2. Open `Admin → Rooms → Room A` in the editor. All elements must appear at sensible grid positions (not far off-screen).
3. Drag an element: verify it snaps to 0.5-unit increments.
4. Resize an element: verify handles appear and snap correctly.
5. Add a Gap element and verify it can be placed and saved.
6. Inspect the SQLite DB directly: `SELECT layout_json FROM rooms LIMIT 1`. All element coordinates must be small numbers (≤ 30), not pixel values (≥ 80).
7. Save and reload: verify coordinates are exactly preserved (no rounding drift).

View File

@@ -1,27 +1,46 @@
# Implementation Plan: Room Editor Refactor (Unified Visualization)
**Objective:** Replace hardcoded seat maps with a unified, dynamic, and high-fidelity room visualization system that works across Admin and Student views.
**Background:**
Currently, the application uses a hardcoded `SeatMap.svelte` for Live Views and Student Check-ins, while using a dynamic `RoomCanvas.svelte` for editing. This leads to data mismatches and prevents users from using custom room layouts. This plan unifies the visualization layer.
**Objective:** Replace the broken hardcoded `SeatMap.svelte` with a unified, dynamic room renderer that works across Admin Live View and Student Check-in.
---
## 1. Unified Visualization Component
## Pre-flight: Existing Bugs This Work Fixes
### Task 1: Create `DynamicRoomView.svelte`
**Files to Create/Modify:**
- `frontend/src/lib/components/DynamicRoomView.svelte`
- (Optionally) Merge into `frontend/src/lib/RoomCanvas.svelte`
Acknowledge these before starting — do not assume current behaviour is correct.
1. **Student check-in seat selection is silently broken in production.** `frontend/src/lib/components/SeatMap.svelte:3358` uses hardcoded seat IDs (`T1-1``T4-5`). These IDs do not exist in any room's `layout_json`, so `POST /api/checkin` is rejected by `backend/src/routes/checkin.rs:200207` (`"invalid seat"`) for every seat click. Migrating to the dynamic renderer is the fix.
2. **Admin live view shows no occupancy data.** `frontend/src/routes/admin/live/[slotId]/+page.svelte:161` calls `<SeatMap variant="tutor" scale={0.78} />` with no `assignments` / `students` props — so even though `attendances` is loaded, nothing renders in the seat map.
3. **Check-in response is mistyped on the frontend.** `s/[code]/+page.svelte:82` treats the response of `POST /api/checkin` as an `Attendance` object, but the backend returns `{"ok": true}` (`checkin.rs:159281`). Currently masked because `loadInfo()` is re-called immediately after. Fix this type error while migrating.
**Note on other consumers of `SeatMap`:** A grep of `frontend/src/` found `SeatMap` is used only in the two routes described below. There are no dashboard, print-view, or mobile-only consumers.
---
## 1. Extend `RoomCanvas` (Decision: Don't Fork)
### Task 1: Add Read-Only / Interactive Modes to `RoomCanvas.svelte`
**Files to Modify:**
- `frontend/src/lib/RoomCanvas.svelte`
**Decision rationale:** `RoomCanvas.svelte:1424` already accepts `occupiedSeatIds`, `mySeatId`, `studentNames`, `selectedId`, `onElementClick`, and `editable`. Creating a separate `DynamicRoomView.svelte` would duplicate the SVG/element rendering logic and risk divergence. **Extend `RoomCanvas` instead.**
**Changes:**
- Create a read-only/interactive component that renders SVG layouts based on the `LayoutElement[]` data.
- **High Fidelity:** Implement the aesthetic details from the design handoff (rounded tables, specific seat styling, label positioning).
- **Responsive Scaling:** Implement an `autoScale` or `viewBox` based system so the room fills the available width on mobile and desktop without breaking coordinates.
- **Interaction Modes:**
- `mode="checkin"`: Seats are clickable for students.
- `mode="notes"`: Seats are clickable for tutors to open note editors.
- `mode="display"`: Read-only view for dashboard/monitoring.
- Add a `clickable: boolean` prop (default `false`) — enables `onElementClick` in read-only mode without enabling edit handles. This maps to the `checkin` and `notes` use cases.
- Remove the hardcoded `width="800" height="600"` (line 65). Replace with a `viewBox` computed from element extents (or a configured canvas size), `preserveAspectRatio="xMidYMid meet"`, and a CSS `width: 100%; height: auto` so the SVG scales responsively to its container. Verify this does not break the editor (pass a fixed `style="width:800px"` wrapper in the editor route).
- Add high-fidelity styling per the design handoff: rounded tables (`rx`/`ry` on rect), specific seat styling (circle or rounded-rect), label positioning (centred on table, below seat icon), seat-state colours (vacant / occupied / mine).
**Prop summary after changes:**
| Prop | Type | Purpose |
|---|---|---|
| `elements` | `LayoutElement[]` | Layout data |
| `editable` | `boolean` | Enables drag, resize, add, delete |
| `clickable` | `boolean` | Enables `onElementClick` in read-only mode |
| `occupiedSeatIds` | `string[]` | Seats to style as occupied |
| `mySeatId` | `string \| null` | Seat to style as "mine" |
| `studentNames` | `Record<string,string>` | Labels overlaid on occupied seats |
| `selectedId` | `string \| null` | Currently selected element (editor) |
| `onElementClick` | `(id: string) => void` | Click callback |
---
@@ -30,37 +49,67 @@ Currently, the application uses a hardcoded `SeatMap.svelte` for Live Views and
### Task 2: Replace `SeatMap` in Admin Live View
**Files to Modify:**
- `frontend/src/routes/admin/live/[slotId]/+page.svelte`
- `backend/src/routes/attendance.rs` *(extend API response)*
**Changes:**
- Replace `SeatMap` with `DynamicRoomView`.
- Connect the `onSeatClick` event to the note-taking and manual attendance logic.
- Ensure attendance data (who sits where) is correctly overlaid on the dynamic layout.
**Pre-step — extend the backend API response (required):**
`attendance.rs:62105` (`get_session_attendance`) returns `SessionAttendance` but does not include room layouts. The page has no way to know the layout. Options:
- **(Recommended)** Extend `SessionAttendance` in `backend/src/models.rs` and `attendance.rs` to include each slot's `layout: Option<Vec<LayoutElement>>` keyed per slot. This avoids an N+1 fetch.
- Alternative: have the page call `api.admin.rooms.get(slot.room_id)` after loading the slot. Simpler but adds a round-trip.
**Frontend changes:**
- Replace `<SeatMap variant="tutor" scale={0.78} />` at line 161 with `<RoomCanvas elements={slot.layout ?? []} clickable={true} occupiedSeatIds={...} studentNames={...} onElementClick={handleSeatClick} />`.
- `occupiedSeatIds`: derive from `attendances.map(a => a.seat_id).filter(Boolean)`.
- `studentNames`: derive from `attendances` as `{ [seat_id]: student.name }`.
- **Seat → student mapping (new logic required):** The existing note-editor is driven by `selectedStudentId` and `toggleAttendance` takes `studentId`. The new `onElementClick(seatId)` must look up `attendances.find(a => a.seat_id === seatId)?.student_id` to populate `selectedStudentId`. Add this mapping in `handleSeatClick`.
### Task 3: Replace `SeatMap` in Student Check-in
**Files to Modify:**
- `frontend/src/routes/s/[code]/+page.svelte`
**Changes:**
- Replace `SeatMap` with `DynamicRoomView`.
- Connect seat selection to the `POST /api/checkin` API.
- Ensure the "current seat" (mySeatId) is visually highlighted in the dynamic view.
**There are 4 call sites, not 1.** `SeatMap` is called at lines 210, 248, 316, and 368 (phone + desktop × seat-pick step + confirmed step). All four need to be replaced.
**Layout data is already on the wire.** `GET /api/checkin/:code` returns `CheckinInfo.layout: LayoutElement[] | null` (populated by `checkin.rs:5368` and typed in `types.ts:8488`), but `s/[code]/+page.svelte:4358` discards `res.layout`. Read and store it: `let layout = $state<LayoutElement[]>([])` and assign `layout = res.layout ?? []`.
**Per call site:**
- Lines 210, 316 (seat-pick step, phone and desktop): Replace with `<RoomCanvas elements={layout} clickable={true} occupiedSeatIds={occupiedSeatIds} mySeatId={null} onElementClick={selectSeat} />`.
- Lines 248, 368 (confirmed step, phone and desktop): Replace with `<RoomCanvas elements={layout} clickable={false} occupiedSeatIds={occupiedSeatIds} mySeatId={myAttendance?.seat_id ?? null} />`.
**Derived state to add:**
```ts
const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[]);
```
**Fix the response-typing bug** (Pre-flight item 3): `api.ts` types `checkin.post` as `Promise<Attendance>` but the backend returns `{ok: true}`. Change the return type to `Promise<{ok: boolean}>` and update `s/[code]/+page.svelte:82` accordingly.
### Task 4: Deprecate `SeatMap.svelte`
**Files to Modify:**
- Delete `frontend/src/lib/components/SeatMap.svelte` once integration is verified.
**Files to Delete:**
- `frontend/src/lib/components/SeatMap.svelte`
**Hard ordering — do not delete until all of the following are true:**
1. All 6 call sites are migrated (1 in admin live view + 4 in `s/[code]`).
2. `grep -rn "SeatMap" frontend/src/` returns zero results.
3. All Playwright tests pass (see Task 5).
---
## 3. Verification
### Automated Tests:
- `frontend/tests/checkin-dynamic.spec.ts`: E2E test to verify student check-in on a **custom-created** room layout.
- `frontend/tests/admin-live-dynamic.spec.ts`: E2E test to verify that tutors can see students on a **custom-created** room layout and click them to leave notes.
### Recommended Ordering
1. Extend the backend API (Task 2 pre-step) — unblocks frontend.
2. Extend `RoomCanvas` with `clickable`, responsive `viewBox`, and high-fidelity styling (Task 1).
3. Migrate `s/[code]` (Task 3) — backend already returns layout; this is the quickest win and immediately unblocks the broken check-in.
4. Migrate admin live view (Task 2) — needs new backend data and the seat→student mapping.
5. Run Playwright tests.
6. Delete `SeatMap.svelte` (Task 4).
### Manual Verification:
1. Create a non-standard room layout in the Admin Editor (e.g., a "U" shape).
### Automated Tests
- `frontend/tests/checkin-dynamic.spec.ts` *(new)*: E2E test — create a custom (non-square) room layout via the API, create a session + slot using it, open the student check-in link, verify the layout renders (not a blank grid), click a seat, verify `POST /api/checkin` succeeds and the seat turns green. Mirror the seat IDs already used in `backend/src/routes/checkin.rs:290653` (`s1`, `s2`).
- `frontend/tests/admin-live-dynamic.spec.ts` *(new)*: E2E test — using the same custom room, manually add attendance for a student on seat `s1`, open the tutor live view, verify the student's name appears on the correct seat.
### Manual Verification
1. Create a U-shaped room layout in `Admin → Rooms`.
2. Create a session and slot using this room.
3. Open the Student Check-in link on a mobile device (browser simulation).
4. Verify the "U" shape is rendered correctly and scaled to fit the screen.
5. Check in as a student and verify the seat turns green.
6. Open the Tutor Live View on a desktop and verify the same student is visible on the same seat in the "U" shape.
3. Open the student check-in link on a mobile viewport. Verify the U-shape is rendered and scaled to fit.
4. Check in as a student by clicking a seat. Verify the seat turns green.
5. Open the tutor Live View on desktop. Verify the same student appears on the correct seat in the U-shape.
6. Click the occupied seat. Verify the note-editor opens for that student.

View File

@@ -1,56 +0,0 @@
# Superadmin CRUD Implementation Plan
**Objective:** Implement a superadmin role to manage courses and tutors, ensuring only authorized users can perform system-wide administrative actions. This feature will be developed in an isolated git worktree.
## Key Context & Decisions
- **Role Strategy:** A new `is_superadmin` boolean column will be added to the `tutors` database table.
- **UI Structure:** A dedicated `/admin/tutors` page will handle tutor management. Course management will remain on `/admin/courses` but will be enhanced with superadmin-only actions (e.g., assigning tutors to courses).
- **Workspace:** Development will be done in `.worktrees/feature-superadmin-crud`.
## Implementation Steps
### 1. Workspace Isolation via Git Worktree
- Create a new git worktree: `git worktree add .worktrees/feature-superadmin-crud -b feature-superadmin-crud`
- All subsequent steps will be performed inside this isolated workspace.
### 2. Database & Models
- Create migration `backend/migrations/002_add_superadmin.sql` to add `is_superadmin BOOLEAN NOT NULL DEFAULT 0` to the `tutors` table.
- Update `backend/demo/demo_seed.sql` to set the default `admin@tutortool.com` as a superadmin (`is_superadmin = 1`).
- Update `backend/src/models.rs` to include `is_superadmin: bool` in the `Tutor` struct.
- Add `CreateTutor` and `TutorResponse` structs to `backend/src/models.rs`.
### 3. Auth & Core Backend
- Modify `backend/src/auth.rs` to include `is_superadmin: bool` in `TutorClaims`. This allows auth guards to check permissions efficiently.
- Update `backend/src/routes/auth_routes.rs` login handler to fetch `is_superadmin` and encode it in the JWT.
- Add a helper function to verify superadmin access to reject unauthorized requests.
### 4. Tutors API
- Create `backend/src/routes/tutors.rs` with endpoints:
- `GET /api/admin/tutors` (list all tutors)
- `POST /api/admin/tutors` (create a tutor, hashing their password)
- `DELETE /api/admin/tutors/:id` (delete a tutor)
- Merge these routes in `backend/src/routes/mod.rs`.
### 5. Course Assignments API
- Modify `backend/src/routes/courses.rs`:
- Enhance `GET /api/admin/courses` to return ALL courses if `claims.is_superadmin` is true, otherwise only return assigned courses.
- Restrict `POST /api/admin/courses` to superadmins only.
- Add `POST /api/admin/courses/:id/tutors` to assign a tutor to a course (superadmin only).
- Add `DELETE /api/admin/courses/:id/tutors/:tutor_id` to remove a tutor from a course (superadmin only).
- Add `GET /api/admin/courses/:id/tutors` to list tutors assigned to a course.
### 6. Frontend Auth & API Client
- Update `frontend/src/lib/types.ts` to include `Tutor` and the new `is_superadmin` flag in token payload or state.
- Add the new endpoints to `frontend/src/lib/api.ts` under `api.admin.tutors` and enhance `api.admin.courses`.
### 7. Frontend UI: Tutors Management
- Update `frontend/src/lib/components/TutorShell.svelte` to conditionally render a "Tutor:innen" link in the sidebar if the user is a superadmin.
- Create `frontend/src/routes/admin/tutors/+page.svelte` following the paper-bg design system. Include a list of tutors and a form to add a new tutor.
### 8. Frontend UI: Courses Enhancements
- Modify `frontend/src/routes/admin/courses/+page.svelte` to show a "Tutor:innen zuweisen" (Assign Tutors) section for each course if the logged-in user is a superadmin.
- Restrict the course creation form to superadmins only.
## Verification & Testing
- Run `cargo test` in the backend to ensure existing tests pass and new route isolation works.
- Perform a manual end-to-end test using the `make dev` script in the new worktree to verify the UI.

View File

@@ -7,12 +7,22 @@ metadata:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
jobTemplate:
spec:
activeDeadlineSeconds: 900
template:
spec:
restartPolicy: OnFailure
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
{{- include "tutortool.selectorLabels" . | nindent 22 }}
topologyKey: kubernetes.io/hostname
containers:
- name: backup
image: alpine:latest

View File

@@ -0,0 +1,40 @@
# Issues Discovered - 2026-05-04
This document summarizes the issues found during the Playwright exploration session on 2026-05-04.
## 1. JSON Parse Error when adding Tutors to Courses
- **Location**: `frontend/src/routes/admin/courses/+page.svelte` (triggered by `api.admin.courses.addTutor`)
- **Symptoms**: Alert box shows `SyntaxError: Failed to execute 'json' on 'Response': Unexpected end of JSON input`.
- **Root Cause**: The backend `POST /api/admin/courses/:id/tutors` handler likely returns a `200 OK` with an empty body. The frontend `api.ts` wrapper attempts to call `.json()` on every response, which fails on empty bodies.
- **Evidence**: Network request #48 in Playwright session showed `status: 200` but `content-length: 0`.
## 2. Attendance Count Missing on Student Check-in Page
- **Location**: `frontend/src/routes/s/[code]/+page.svelte`
- **Symptoms**: The "Anwesende" count shows `0 / 11` even after a successful check-in or when other students are present.
- **Root Cause**: The `loadInfo` function fetches the data but never assigns `res.attendances` to the local `attendances` state variable.
- **Evidence**: `loadInfo` contains `const checkinAttendances = res.attendances ?? [];` but lacks `attendances = checkinAttendances;`.
## 3. Seat Map Empty in Live View
- **Location**: `frontend/src/routes/admin/live/[slotId]/+page.svelte`
- **Symptoms**: All seats show as "frei" in the tutor's live view, even when students are checked in and assigned to seats.
- **Root Cause**: The `SeatMap` component is instantiated without passing `assignments` or `students` props.
- **Evidence**: `<SeatMap variant="tutor" scale={0.78} />` is used without other props in the source code.
## 4. Room Editor Element Selection Broken
- **Location**: `frontend/src/lib/RoomCanvas.svelte`
- **Symptoms**: Clicking an element in the Room Layout Editor does not select it (sidebar continues to show "Element auswählen").
- **Root Cause**: In `handleMouseDown`, if `editable` is true, the function returns early without calling `onElementClick`.
- **Evidence**:
```typescript
function handleMouseDown(e: MouseEvent, el: LayoutElement) {
if (!editable) {
onElementClick?.(el);
return;
}
draggingId = el.id;
// ...
}
```
## 5. Potential UI/UX: Dashboard Slot Status
- **Observation**: Dashboard sometimes shows "Anwesenheit offen" but "Alle Slots 1" and "Abgeschlossene Slots 0". It's a bit confusing if there is only one slot ever. (Minor)

View File

@@ -0,0 +1,263 @@
# TutorTool — Unified Bug Fixes, Tutor Lifecycle, Room Editor Refactor
## Context
A Playwright exploration session on **2026-05-04** (`docs/issues_discovered_20260504.md`) surfaced four production frontend bugs on top of an existing security backlog (RUSTSEC-2023-0071) and a half-finished Room Editor refactor. Three reference plans already exist in `conductor/`:
- `conductor/room-editor-refactor-core.md` — pixel→grid migration + editor robustification
- `conductor/room-editor-refactor-visualization.md` — replace hardcoded `SeatMap.svelte` with dynamic `RoomCanvas`
- `conductor/unified-refactor-and-fixes.md` — already merges the above with security/quick fixes (RUSTSEC, JSON parse, check-in typing, admin logout)
This plan **supersedes** `conductor/unified-refactor-and-fixes.md`, folds in two newly verified items:
1. **Admin/tutor deletion is opaquely broken.** `DELETE /api/admin/tutors/{id}` exists (`backend/src/routes/tutors.rs:74-94`) but the three FK references — `tutor_courses.tutor_id`, `slots.tutor_id`, `notes.tutor_id` (`backend/migrations/001_initial.sql:15,44,65`) — declare no `ON DELETE` clause, so SQLite RESTRICT raises a generic FK error → 500 → `alert("internal error")`.
2. **Issue #4 in the discovered-issues doc has its condition inverted.** `frontend/src/lib/RoomCanvas.svelte:30-38` is `if (!editable) { onElementClick?.(el); return; }`, so click-to-select fails **in edit mode** (the opposite of what the doc says). Fix: in edit mode, also fire `onElementClick` before initiating the drag.
The intended outcome is one continuous batch of work that ships: secure auth (logout + RUSTSEC patch), correct frontend data flow, a real tutor lifecycle (deactivate + delete with safety), and a unified, dynamic room renderer that fixes the silently-broken student check-in.
> **Memory housekeeping (post-approval):** the SLM memory `1728144c207346e2` ("admin CRUD enforces a permanent restriction: no tutors can be deleted") is **stale and contradicted by the code**. Update or remove it after this plan is approved.
---
## Phase 0 — Git Workflow
### 0.1 Create a worktree
From your main clone (assume it lives at `~/tutortool`):
```sh
git worktree add ../tutortool-unified main
cd ../tutortool-unified
git switch -c feature/unified-fixes-room-editor
```
All implementation work happens in `../tutortool-unified`.
The original clone stays clean on `main`.
### 0.2 Commit discipline
- One logical commit per Phase/sub-phase, e.g.:
- `feat(security): fix RUSTSEC-2023-0071 via aws_lc_rs`
- `fix(api): handle empty 200 body + fix check-in typing`
- `feat(tutors): add is_active flag, deactivate + safe delete`
- `feat(rooms): pixel→grid migration + layout validators`
- `feat(canvas): fix drag/resize, click-to-select in edit mode`
- `feat(viz): replace SeatMap with dynamic RoomCanvas`
- `make lint && make test` must pass **before** each commit.
Gate with a pre-commit hook if you like:
```sh
# .git/hooks/pre-commit
#!/bin/sh
cd backend && cargo clippy --all-targets -- -D warnings && cargo test
```
### 0.3 Open the PR
Once all phases are done and CI is green:
```sh
git push -u origin feature/unified-fixes-room-editor
# then open a PR against main via Gitea/GitHub UI
# title: "Unified fixes, tutor lifecycle, room editor refactor"
# body: link this plan doc, list phases, reference Playwright spec results
```
Merge strategy: **squash-merge** each phase-branch if you split work, or a single **merge commit** if working on one branch (keeps phase history readable).
### 0.4 Post-merge cleanup
```sh
cd ~/tutortool # back to main clone
git pull # fast-forward to merged state
git worktree remove ../tutortool-unified
git branch -d feature/unified-fixes-room-editor
```
### 0.5 Verification gate before merge
PR must have:
- [ ] `make test` passes (all backend + new Phase 2.4/3.2/3.7 tests)
- [ ] `make lint` passes (zero Clippy warnings, zero TS errors)
- [ ] `sqlx migrate run` clean on fresh DB (migrations 003 + 004)
- [ ] All new Playwright specs green (`rooms`, `checkin-dynamic`, `admin-live-dynamic`, `admin-tutors`, `admin-rooms-delete`)
- [ ] `cargo audit` clean (no RUSTSEC-2023-0071)
## Phase 1 — Security & Quick Fixes
### 1.1 RUSTSEC-2023-0071 (Marvin Attack)
- `backend/Cargo.toml` — set `jsonwebtoken` to `features = ["aws_lc_rs"]`.
- `backend/audit.toml` — remove `ignore = ["RUSTSEC-2023-0071"]`.
- `.gitea/workflows/ci.yml` and `.gitea/workflows/release.yml` — drop `--ignore RUSTSEC-2023-0071`.
### 1.2 JSON parse error on empty 200 body
- `frontend/src/lib/api.ts:46` — currently only 204 is short-circuited. Extend the empty-body branch to also handle 200-with-empty-body (probe `content-length === '0'` or fall through `await res.text()` and return `{} as T` if empty). Triggers from `assignTutor`, `unassignTutor`, etc.
### 1.3 Check-in API typing
- `frontend/src/lib/api.ts` — `checkin.post` currently typed `Promise<Attendance>`; backend returns `{ok: true}`. Change to `Promise<{ok: boolean}>`.
- `frontend/src/routes/s/[code]/+page.svelte:82` — drop the local-state assignment from the response; rely on the subsequent `loadInfo()` to populate `myAttendance`.
### 1.4 Attendance count not assigned (issues doc #2)
- `frontend/src/routes/s/[code]/+page.svelte` `loadInfo` — assign `attendances = checkinAttendances` so the "Anwesende N / M" counter updates.
### 1.5 Admin logout UI
- `frontend/src/lib/components/TutorShell.svelte` (where the actual sidebar nav lives — `frontend/src/routes/admin/+layout.svelte` only renders the shell) — add an "Abmelden" entry at the bottom of the nav. On click: `await api.auth.logout()` → `auth.logout()` → `goto('/admin/login')`. Re-export `auth` and `api` if needed.
---
## Phase 2 — Tutor Lifecycle: Deactivate **and** Delete
The user wants **both** soft-deactivate (preserve history, hide from pickers) **and** real hard-delete (with safety pre-check).
### 2.1 Schema migration
- New file `backend/migrations/004_tutor_is_active.sql`:
```sql
ALTER TABLE tutors ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT 1;
```
No backfill needed; existing rows default to active.
### 2.2 Backend changes
**File:** `backend/src/routes/tutors.rs`
- **`Tutor` model** (`backend/src/models.rs:11-17`) — add `is_active: bool`.
- **`list_tutors`** — include `is_active` in SELECT and JSON output. Do **not** filter; the admin needs to see inactive tutors to reactivate them.
- **New: `set_tutor_active(id, {is_active: bool})`** — `PATCH /api/admin/tutors/{id}/active` body `{is_active: bool}`. Superadmin-only. Forbid self-deactivation (mirror the self-delete guard at `tutors.rs:84-86` — return `AppError::Conflict("cannot deactivate yourself")`).
- **`delete_tutor`** (existing handler) — before the `DELETE FROM tutors`, run three `SELECT COUNT(*)` queries inside a single connection (no need for an explicit tx; SQLite SELECTs see committed state):
- `tutor_courses WHERE tutor_id = ?`
- `slots WHERE tutor_id = ?`
- `notes WHERE tutor_id = ?`
If any non-zero, return `AppError::Conflict(format!("Tutor:in hat noch {c} Kurszuordnung(en), {s} Slot(s) und {n} Notiz(en). Bitte zuerst entfernen oder deaktivieren."))`.
`AppError::Conflict` already maps to 409 with `{"error": msg}` (`backend/src/error.rs`).
- **Auth login** (`backend/src/routes/auth_routes.rs:22-96`) — reject inactive tutors with `AppError::Unauthorized` (same response shape as wrong-password to avoid info leakage). Add this check after password verification.
- **Tutor pickers** — grep for `SELECT … FROM tutors` outside of `list_tutors` and `auth_routes.rs`. Likely sites: `backend/src/routes/courses.rs` (tutor assignment list), `backend/src/routes/sessions.rs` or `slots` creation flow. Each must add `WHERE is_active = 1`.
### 2.3 Frontend changes
**File:** `frontend/src/routes/admin/tutors/+page.svelte`
- Show three status pills: `Superadmin` / `Tutor:in` / `Inaktiv` (combine `is_superadmin` + `is_active`).
- Add a per-row "Deaktivieren" button (or "Aktivieren" if already inactive) that calls `api.admin.tutors.setActive(id, !is_active)`.
- Keep the existing "Löschen" button — error message from 409 already surfaces via the existing `catch (err) { alert(err.message) }` because `request<T>` extracts `error.error` (`api.ts:42-43`). Optionally: replace alert with an inline error region above the table for better UX.
- **API client** `frontend/src/lib/api.ts` — add `api.admin.tutors.setActive(id, is_active)` calling `PATCH /admin/tutors/{id}/active`.
### 2.4 Tests (`backend/src/routes/tutors.rs` `#[cfg(test)]` block)
Pattern after `backend/src/routes/rooms.rs:184-322` and use `backend/src/test_helpers.rs`.
- `delete_tutor_with_no_refs_succeeds` → 204, row gone.
- `delete_tutor_with_course_assignment_returns_409` → body contains `Kurszuordnung`.
- `delete_tutor_with_slot_returns_409` → body contains `Slot`.
- `delete_tutor_with_note_returns_409` → body contains `Notiz`.
- `delete_self_returns_409` (existing branch coverage).
- `set_active_false_then_login_fails` — deactivate, attempt login, expect 401.
- `set_active_false_hides_from_pickers` — verify any tutor-picker endpoint doesn't list inactive tutors.
- `cannot_deactivate_self_returns_409`.
---
## Phase 3 — Room Editor Core
Reference: `conductor/room-editor-refactor-core.md`. One-liner per task:
- **3.1 Pixel→grid migration** — new `backend/migrations/003_normalize_room_layout_units.sql`: parse `layout_json` per row; if any of x/y/w/h > 50, divide all four by 40 and rewrite. Update `backend/demo/demo_seed.sql:16-41` to grid units. Update any `rooms.rs` tests asserting pixel-scale numbers.
- **Note on numbering:** this is migration `003`; tutor `is_active` is `004`. Apply order matters because `003` only touches `rooms`, `004` only touches `tutors` — no cross-dependency.
- **3.2 Additive layout validators** — `backend/src/routes/rooms.rs:18-69`: add `MAX_CANVAS = 100` upper-bound + 0.5-step multiple checks; one test per validator.
- **3.3 RoomCanvas drag/resize hardening** — `frontend/src/lib/RoomCanvas.svelte`: bind `mousemove`/`mouseup` to `window` while dragging (currently bound to SVG only at lines 69-71); build resize handles + state from scratch; default snap step 0.5 (configurable via `snapStep` prop).
- **3.4 Fix click-to-select in edit mode (issues #4 corrected)** — `frontend/src/lib/RoomCanvas.svelte:30-38`: when `editable`, still call `onElementClick?.(el)` (so the parent updates `selectedId`) before starting the drag. Do **not** early-return.
- **3.5 Editor UI improvements** — `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`: add X/Y `step="0.5"` numeric inputs, "+ Gap" button, "Snap to Grid" toggle, "Duplicate element" (UUID + 1-unit offset), inline error region for `saveLayout` failures (currently only `console.error`).
- **3.6 Robust seat labelling** — same file: replace the brittle `(filter + 1)` next-label with `max(existingLabels) + 1`.
- **3.7 Room and Element deletion**
- **Element deletion** is already wired in the editor sidebar (`+page.svelte:101`); validate it still works post-migration.
- **Room deletion (new)**:
- Backend: `DELETE /api/admin/rooms/{id}` in `backend/src/routes/rooms.rs`. Pre-check `SELECT COUNT(*) FROM slots WHERE room_id = ?`; if non-zero, 409 with `format!("Raum hat noch {n} Slot(s). Bitte zuerst entfernen.")`. Otherwise plain DELETE.
- Frontend: `frontend/src/routes/admin/rooms/+page.svelte` — add per-row "Löschen" button with `confirm()` dialog; error message surfaces via the existing alert path.
- API client: `api.admin.rooms.delete(id)`.
- Tests: `delete_room_with_no_slots_succeeds`, `delete_room_with_slot_returns_409`.
---
## Phase 4 — Unified Visualization (Dynamic RoomCanvas)
Reference: `conductor/room-editor-refactor-visualization.md`. One-liner per task:
- **4.1 Extend RoomCanvas** — `frontend/src/lib/RoomCanvas.svelte`: add `clickable: boolean = false` prop (orthogonal to 3.4 — `clickable` enables `onElementClick` in **read-only** mode); replace fixed `width="800" height="600"` (line 65) with computed `viewBox` + `preserveAspectRatio="xMidYMid meet"` + CSS `width:100%;height:auto`; verify the editor route still bounds the SVG (wrap with `style="width:800px"` if needed); apply seat/table styling from the design handoff (rounded tables, occupied/mine colour states, label positioning).
- **4.2 Backend: ship layout with attendance** — `backend/src/routes/attendance.rs:62-105` + `backend/src/models.rs`: extend `SessionAttendance` with per-slot `layout: Option<Vec<LayoutElement>>` to avoid an N+1.
- **4.3 Admin live view migration** — `frontend/src/routes/admin/live/[slotId]/+page.svelte:161`: replace `<SeatMap variant="tutor" scale={0.78} />` with `<RoomCanvas elements={slot.layout ?? []} clickable occupiedSeatIds={…} studentNames={…} onElementClick={handleSeatClick} />`. Implement `handleSeatClick(seatId)` mapping seat→student via `attendances.find(a => a.seat_id === seatId)?.student_id` → `selectedStudentId` (drives the existing note editor).
- **4.4 Student check-in migration (4 sites)** — `frontend/src/routes/s/[code]/+page.svelte` lines 210, 248, 316, 368: read `res.layout` in `loadInfo` (`let layout = $state<LayoutElement[]>([])`); replace each call. Lines 210/316 (seat-pick): `clickable` + `onElementClick={selectSeat}`. Lines 248/368 (confirmed): read-only with `mySeatId={myAttendance?.seat_id ?? null}`. Add `const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[])`.
- **4.5 Delete `frontend/src/lib/components/SeatMap.svelte`** — only after `grep -rn "SeatMap" frontend/src/` returns zero hits and Playwright passes.
---
## Verification
### Automated
- `make test` — backend suite passes, including:
- Phase 2.4 tutor delete + activate tests
- Phase 3.2 layout validator tests
- Phase 3.7 room delete tests
- `sqlx migrate run` against a clean DB — migrations 003 and 004 apply cleanly.
- `make lint` — zero warnings (per the project's mandate).
- `frontend/tests/rooms.spec.ts` _(new)_ — create room, drag/snap, save/reload, assert coords preserved.
- `frontend/tests/checkin-dynamic.spec.ts` _(new)_ — custom layout, click seat, `POST /api/checkin` succeeds, seat turns green.
- `frontend/tests/admin-live-dynamic.spec.ts` _(new)_ — student appears on correct seat in tutor live view.
- `frontend/tests/admin-tutors.spec.ts` _(new)_ — (i) delete tutor with no refs → row disappears; (ii) delete tutor attached to a course → red error mentions `Kurszuordnung`; unassign → retry succeeds; (iii) deactivate → tutor cannot log in; (iv) reactivate → tutor can log in again.
- `frontend/tests/admin-rooms-delete.spec.ts` _(new)_ — delete unused room succeeds; delete room attached to a slot → 409 message visible.
### Manual
1. `make seed-demo`. Open `Admin → Rooms → Room A` — all elements at sensible grid positions (no values > 50).
2. Drag and resize an element; release outside the SVG; confirm no stranded drag.
3. Click an element in **edit mode** — sidebar populates (issue #4 regression).
4. Add a Gap; toggle Snap; Duplicate an element; trigger a deliberate save error and confirm the inline message.
5. Create a U-shaped room; attach to a session/slot.
6. Open the student check-in link on a mobile viewport — U-shape renders, seat-click checks in, seat turns green.
7. Open tutor Live View — student name on correct seat; click opens note editor.
8. **Tutor lifecycle:** as superadmin, create a tutor "Test", attach to a course → click `Löschen` → red message lists `1 Kurszuordnung`. Click `Deaktivieren` → status pill flips to `Inaktiv`; attempt to log in as that tutor → fails. Click `Aktivieren` → login succeeds again. Unassign from course → click `Löschen` → succeeds.
9. **Room lifecycle:** delete a room with no slots → row disappears. Delete a room attached to a slot → 409 message visible.
10. Click `Abmelden` in admin sidebar → redirect to `/admin/login`; refresh → still logged out.
11. `cargo audit` (without the ignore flag) — no RUSTSEC-2023-0071 finding.
---
## Critical files
- `backend/Cargo.toml`
- `backend/audit.toml`
- `.gitea/workflows/ci.yml`, `.gitea/workflows/release.yml`
- `backend/migrations/003_normalize_room_layout_units.sql` _(new)_
- `backend/migrations/004_tutor_is_active.sql` _(new)_
- `backend/demo/demo_seed.sql`
- `backend/src/models.rs`
- `backend/src/routes/tutors.rs`
- `backend/src/routes/rooms.rs`
- `backend/src/routes/attendance.rs`
- `backend/src/routes/auth_routes.rs`
- `backend/src/routes/courses.rs` (tutor picker filter)
- `backend/src/routes/sessions.rs` (tutor picker filter)
- `frontend/src/lib/api.ts`
- `frontend/src/lib/types.ts`
- `frontend/src/lib/RoomCanvas.svelte`
- `frontend/src/lib/components/SeatMap.svelte` _(delete in Phase 4.5)_
- `frontend/src/lib/components/TutorShell.svelte` (logout button)
- `frontend/src/routes/admin/tutors/+page.svelte`
- `frontend/src/routes/admin/rooms/+page.svelte`
- `frontend/src/routes/admin/rooms/[roomId]/+page.svelte`
- `frontend/src/routes/admin/live/[slotId]/+page.svelte`
- `frontend/src/routes/s/[code]/+page.svelte`
---
## Post-implementation memory hygiene (do after the plan ships)
- Update SLM memory `1728144c207346e2` to reflect the new lifecycle (deactivate + safe delete) instead of the stale "no tutors can be deleted" claim.
- Store a new SLM memory capturing the deletion-policy decision (deactivate-or-block-with-409) so future sessions don't re-litigate it.

View File

@@ -80,6 +80,7 @@ body, .ui {
.pill.open { color: var(--amber); background: rgba(176,125,42,0.08); }
.pill.closed { color: var(--ink-3); }
.pill.inactive { color: var(--ink-4); border-style: dashed; }
.pill.locked { color: var(--accent); background: rgba(138,44,31,0.06); }
.pill.present { color: var(--green); background: rgba(74,107,58,0.08); }
.pill.absent { color: var(--accent); }

View File

@@ -4,7 +4,9 @@
interface Props {
elements: LayoutElement[];
editable?: boolean;
onElementClick?: (el: LayoutElement) => void;
clickable?: boolean;
snapStep?: number;
onElementClick?: (el: LayoutElement | null) => void;
onLayoutChange?: (elements: LayoutElement[]) => void;
selectedId?: string | null;
occupiedSeatIds?: string[];
@@ -15,6 +17,8 @@
let {
elements = $bindable([]),
editable = false,
clickable = false,
snapStep = 0.5,
onElementClick,
onLayoutChange,
selectedId = null,
@@ -24,57 +28,148 @@
}: Props = $props();
let draggingId = $state<string | null>(null);
let resizingId = $state<string | null>(null);
let resizeDir = $state<'e' | 's' | 'se' | null>(null);
let startX = 0;
let startY = 0;
let initialX = 0;
let initialY = 0;
let initialW = 0;
let initialH = 0;
let dragMoved = false;
const GRID_SIZE = 40;
function handleMouseDown(e: MouseEvent, el: LayoutElement) {
if (!editable) {
if (clickable && !editable) {
onElementClick?.(el);
return;
}
if (!editable) return;
draggingId = el.id;
startX = e.clientX - el.x * 40;
startY = e.clientY - el.y * 40;
dragMoved = false;
startX = e.clientX;
startY = e.clientY;
initialX = el.x;
initialY = el.y;
e.stopPropagation();
}
function handleMouseMove(e: MouseEvent) {
if (!draggingId || !editable) return;
const index = elements.findIndex((el: LayoutElement) => el.id === draggingId);
if (index === -1) return;
const el = elements[index];
if (!el) return;
const newX = Math.round((e.clientX - startX) / 10) * 10 / 40;
const newY = Math.round((e.clientY - startY) / 10) * 10 / 40;
elements[index] = { ...el, x: newX, y: newY };
function handleResizeDown(e: MouseEvent, el: LayoutElement, dir: 'e' | 's' | 'se') {
if (!editable) return;
resizingId = el.id;
resizeDir = dir;
startX = e.clientX;
startY = e.clientY;
initialW = el.width;
initialH = el.height;
e.stopPropagation();
}
function handleMouseUp() {
if (draggingId && editable) {
onLayoutChange?.(elements);
function handleWindowMouseMove(e: MouseEvent) {
if (!editable) return;
if (draggingId) {
dragMoved = true;
const index = elements.findIndex(el => el.id === draggingId);
if (index !== -1) {
const dx = (e.clientX - startX) / GRID_SIZE;
const dy = (e.clientY - startY) / GRID_SIZE;
let newX = initialX + dx;
let newY = initialY + dy;
if (snapStep > 0) {
newX = Math.round(newX / snapStep) * snapStep;
newY = Math.round(newY / snapStep) * snapStep;
}
const currentEl = elements[index];
if (currentEl) {
elements[index] = { ...currentEl, x: Math.max(0, newX), y: Math.max(0, newY) };
}
}
} else if (resizingId && resizeDir) {
const index = elements.findIndex(el => el.id === resizingId);
if (index !== -1) {
const dx = (e.clientX - startX) / GRID_SIZE;
const dy = (e.clientY - startY) / GRID_SIZE;
let newW = initialW;
let newH = initialH;
if (resizeDir.includes('e')) newW = initialW + dx;
if (resizeDir.includes('s')) newH = initialH + dy;
if (snapStep > 0) {
newW = Math.round(newW / snapStep) * snapStep;
newH = Math.round(newH / snapStep) * snapStep;
}
const currentEl = elements[index];
if (currentEl) {
elements[index] = {
...currentEl,
width: Math.max(snapStep || 0.1, newW),
height: Math.max(snapStep || 0.1, newH)
};
}
}
}
}
function handleWindowMouseUp() {
if (editable) {
if (draggingId) {
if (dragMoved) {
onLayoutChange?.(elements);
} else {
const el = elements.find(e => e.id === draggingId);
if (el) onElementClick?.(el);
}
} else if (resizingId) {
onLayoutChange?.(elements);
}
}
draggingId = null;
resizingId = null;
resizeDir = null;
dragMoved = false;
}
const GRID_SIZE = 40;
// Compute viewBox based on elements or default
let maxX = $derived(elements.reduce((max, el) => Math.max(max, el.x + el.width), 20));
let maxY = $derived(elements.reduce((max, el) => Math.max(max, el.y + el.height), 15));
let viewBox = $derived(`0 0 ${Math.ceil(maxX * GRID_SIZE)} ${Math.ceil(maxY * GRID_SIZE)}`);
</script>
<svelte:window
onmousemove={handleWindowMouseMove}
onmouseup={handleWindowMouseUp}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<svg
width="800"
height="600"
{viewBox}
preserveAspectRatio="xMidYMid meet"
class="room-canvas"
class:editable
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
class:clickable={clickable || editable}
onclick={(e) => {
// Deselect if clicking on empty canvas
if (editable && e.target === e.currentTarget) {
onElementClick?.(null);
}
}}
>
<!-- Grid -->
{#if editable}
{#if editable && snapStep > 0}
<defs>
<pattern id="grid" width={GRID_SIZE} height={GRID_SIZE} patternUnits="userSpaceOnUse">
<path d="M {GRID_SIZE} 0 L 0 0 0 {GRID_SIZE}" fill="none" stroke="#eee" stroke-width="1"/>
<pattern id="grid" width={GRID_SIZE * snapStep} height={GRID_SIZE * snapStep} patternUnits="userSpaceOnUse">
<path d="M {GRID_SIZE * snapStep} 0 L 0 0 0 {GRID_SIZE * snapStep}" fill="none" stroke="var(--rule-soft)" stroke-width="1" stroke-dasharray="2,2"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
@@ -93,7 +188,7 @@
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
rx="4"
rx="8"
/>
<text
x={(el.width * GRID_SIZE) / 2}
@@ -106,7 +201,7 @@
{#if studentNames[el.id]}
<text
x={(el.width * GRID_SIZE) / 2}
y={(el.height * GRID_SIZE) + 15}
y={(el.height * GRID_SIZE) + 14}
text-anchor="middle"
class="student-name"
>
@@ -117,72 +212,124 @@
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
fill="#e9ecef"
stroke="#dee2e6"
rx="16"
/>
{:else if el.type === 'gap'}
<!-- Invisible interaction target -->
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
fill="transparent"
stroke={editable ? "var(--rule)" : "none"}
stroke-dasharray="4,4"
/>
{#if editable}
<text
x={(el.width * GRID_SIZE) / 2}
y={(el.height * GRID_SIZE) / 2}
text-anchor="middle"
dominant-baseline="middle"
style="font-size: 10px; fill: var(--ink-4);"
>
GAP
</text>
{/if}
{:else if el.type === 'door'}
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
fill="#ffeeba"
stroke="#ffe082"
fill="transparent"
stroke="var(--rule)"
stroke-width="2"
/>
<text
x={(el.width * GRID_SIZE) / 2}
y={(el.height * GRID_SIZE) / 2}
text-anchor="middle"
dominant-baseline="middle"
style="font-size: 10px;"
style="font-size: 10px; fill: var(--ink-3); font-family: var(--mono); letter-spacing: 0.1em;"
>
DOOR
</text>
{/if}
{#if editable && selectedId === el.id}
<!-- Resize handles -->
<rect class="resize-handle e" x={el.width * GRID_SIZE - 4} y={(el.height * GRID_SIZE) / 2 - 4} width="8" height="8" onmousedown={(e) => handleResizeDown(e, el, 'e')} />
<rect class="resize-handle s" x={(el.width * GRID_SIZE) / 2 - 4} y={el.height * GRID_SIZE - 4} width="8" height="8" onmousedown={(e) => handleResizeDown(e, el, 's')} />
<rect class="resize-handle se" x={el.width * GRID_SIZE - 4} y={el.height * GRID_SIZE - 4} width="8" height="8" onmousedown={(e) => handleResizeDown(e, el, 'se')} />
{/if}
</g>
{/each}
</svg>
<style>
.room-canvas {
background: white;
border: 1px solid #ccc;
background: transparent;
width: 100%;
height: auto;
user-select: none;
touch-action: none;
}
.room-canvas.editable {
cursor: crosshair;
}
.element {
.room-canvas.clickable .element {
cursor: pointer;
}
.element rect {
transition: fill 0.2s, stroke 0.2s;
}
.element.seat rect {
fill: #fff;
stroke: #007bff;
stroke-width: 2;
fill: #fbf7ee;
stroke: var(--ink-2);
stroke-width: 1.5;
}
.element.seat text {
fill: #007bff;
font-weight: bold;
font-size: 14px;
fill: var(--ink);
font-weight: 500;
font-family: var(--mono);
font-size: 12px;
}
.element.seat.occupied rect {
fill: #e7f1ff;
stroke: #6c757d;
fill: #d6cdb5;
stroke: var(--ink-2);
}
.element.seat.occupied text {
fill: #6c757d;
fill: var(--ink-2);
}
.element.seat.is-mine rect {
fill: #28a745;
stroke: #1e7e34;
fill: var(--accent);
stroke: var(--accent);
}
.element.seat.is-mine text {
fill: white;
}
.element.selected rect {
stroke: #ffc107;
stroke-width: 3;
stroke: var(--highlight);
stroke-width: 2.5;
}
.element.table rect {
fill: rgba(0,0,0,0.03);
stroke: var(--rule);
stroke-width: 1;
}
.student-name {
font-size: 10px;
fill: #333;
font-size: 11px;
fill: var(--ink-3);
font-family: var(--sans);
}
.resize-handle {
fill: white;
stroke: var(--accent);
stroke-width: 1.5;
}
.resize-handle.e { cursor: ew-resize; }
.resize-handle.s { cursor: ns-resize; }
.resize-handle.se { cursor: nwse-resize; }
</style>

View File

@@ -7,6 +7,8 @@ import { auth } from './auth.svelte';
const BASE = '/api';
let isRefreshing = false;
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...init,
@@ -18,6 +20,20 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
});
if (res.status === 401 && browser) {
if (!isRefreshing && path !== '/auth/refresh') {
isRefreshing = true;
try {
const refreshed = await fetch(BASE + '/auth/refresh', { method: 'POST', credentials: 'include' });
if (refreshed.ok) {
isRefreshing = false;
return request<T>(path, init);
}
} catch (_e) {
// refresh failed, fall through to logout
} finally {
isRefreshing = false;
}
}
auth.logout();
throw new Error('Unauthorized');
}
@@ -28,7 +44,10 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
if (res.status === 204) return {} as T;
return res.json() as Promise<T>;
const text = await res.text();
if (!text) return {} as T;
return JSON.parse(text) as T;
}
export const api = {
@@ -80,6 +99,11 @@ export const api = {
body: JSON.stringify(tutor)
}),
delete: (id: number) => request<void>(`/admin/tutors/${id}`, { method: 'DELETE' }),
setActive: (id: number, is_active: boolean) =>
request<void>(`/admin/tutors/${id}/active`, {
method: 'PATCH',
body: JSON.stringify({ is_active })
}),
},
students: {
delete: (id: number) => request<void>(`/admin/students/${id}`, { method: 'DELETE' }),
@@ -99,6 +123,7 @@ export const api = {
method: 'PUT',
body: JSON.stringify(layout)
}),
delete: (id: number) => request<void>(`/admin/rooms/${id}`, { method: 'DELETE' }),
},
sessions: {
list: (course_id: number) => request<Session[]>(`/admin/sessions?course_id=${course_id}`),
@@ -147,7 +172,7 @@ export const api = {
getInfo: (code: string) => request<CheckinInfo>(`/checkin/${code}`),
getStudents: (code: string) => request<Student[]>(`/checkin/${code}/students`),
post: (code: string, student_id: number, seat_id?: string) =>
request<Attendance>('/checkin', {
request<{ok: boolean}>('/checkin', {
method: 'POST',
body: JSON.stringify({ code, student_id, seat_id })
}),

View File

@@ -14,7 +14,13 @@ export const auth = {
async init() {
if (!browser || _initialized) return;
try {
const res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
let res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
if (!res.ok) {
const refreshed = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
if (refreshed.ok) {
res = await fetch(`/api/auth/me?t=${Date.now()}`, { credentials: 'include' });
}
}
if (res.ok) {
const me = await res.json();
_isSuperadmin = me.is_superadmin;

View File

@@ -1,169 +0,0 @@
<script lang="ts">
interface SeatDef {
id: string;
x: number;
y: number;
table: string;
}
interface StudentRef {
id: number;
name: string;
initials: string;
}
const {
assignments = {},
students = [],
selectedStudent = null,
onSeatClick,
variant = 'tutor',
ownSeat = null,
scale = 1,
} = $props<{
assignments?: Record<string, number>;
students?: StudentRef[];
selectedStudent?: number | null;
onSeatClick?: (seat: SeatDef) => void;
variant?: 'tutor' | 'student' | 'student-self';
ownSeat?: string | null;
scale?: number;
}>();
const W = 760;
const H = 460;
const WALLS = { x: 12, y: 12, w: 736, h: 436 };
const WINDOW = { x: 12, y: 120, w: 6, h: 220 };
const DOOR = { x: 60, y: 448, w: 70, h: 6 };
const BEAMER = { x: 372, y: 24, w: 110, h: 8 };
const PODIUM = { x: 332, y: 60, w: 190, h: 38 };
const TABLES = [
{ id: 'T1', x: 90, y: 150, w: 200, h: 70, label: 'T1' },
{ id: 'T2', x: 470, y: 150, w: 200, h: 70, label: 'T2' },
{ id: 'T3', x: 90, y: 320, w: 200, h: 70, label: 'T3' },
{ id: 'T4', x: 470, y: 320, w: 200, h: 70, label: 'T4' },
];
function makeSeats(): SeatDef[] {
return TABLES.flatMap((t) => [
{ id: `${t.id}-1`, x: t.x + t.w * 0.28, y: t.y - 22, table: t.id },
{ id: `${t.id}-2`, x: t.x + t.w * 0.72, y: t.y - 22, table: t.id },
{ id: `${t.id}-3`, x: t.x + t.w * 0.28, y: t.y + t.h + 22, table: t.id },
{ id: `${t.id}-4`, x: t.x + t.w * 0.72, y: t.y + t.h + 22, table: t.id },
{ id: `${t.id}-5`, x: t.x + t.w + 26, y: t.y + t.h / 2, table: t.id },
]);
}
const SEATS = makeSeats();
function studentById(id: number): StudentRef | undefined {
return students.find((s: StudentRef) => s.id === id);
}
function seatStyle(seat: SeatDef): { bg: string; border: string; label: string; labelColor: string; shadow: string } {
const sid = assignments[seat.id];
const student = sid ? studentById(sid) : undefined;
const isSelected = selectedStudent != null && sid === selectedStudent;
const isOwn = ownSeat === seat.id;
if (variant === 'tutor') {
if (student) {
return {
bg: isSelected ? 'var(--ink)' : '#fbf7ee',
border: isSelected ? 'var(--ink)' : 'var(--ink-2)',
label: student.initials,
labelColor: isSelected ? '#f7eedc' : 'var(--ink)',
shadow: isSelected ? '0 0 0 3px rgba(241,211,106,0.6)' : 'none',
};
}
return { bg: '#f7f1e3', border: 'var(--ink-4)', label: '', labelColor: 'var(--ink-4)', shadow: 'none' };
}
if (variant === 'student') {
if (isOwn) {
return { bg: 'var(--accent)', border: 'var(--accent)', label: '★', labelColor: '#f7eedc', shadow: 'none' };
}
if (sid) {
return { bg: '#d6cdb5', border: 'var(--ink-4)', label: '', labelColor: '', shadow: 'none' };
}
return { bg: '#fbf7ee', border: 'var(--ink-2)', label: '', labelColor: '', shadow: 'none' };
}
// student-self (read-only)
if (isOwn) {
return { bg: 'var(--accent)', border: 'var(--accent)', label: '★', labelColor: '#f7eedc', shadow: 'none' };
}
if (sid) {
return { bg: '#d6cdb5', border: 'var(--ink-4)', label: '', labelColor: '', shadow: 'none' };
}
return { bg: '#fbf7ee', border: 'var(--ink-3)', label: '', labelColor: '', shadow: 'none' };
}
</script>
<div style="position:relative;width:{W * scale}px;height:{H * scale}px;background:#f7f1e3;border:1px solid var(--rule);border-radius:4px;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.02),0 1px 0 rgba(0,0,0,0.03)">
<!-- Inner graph-paper grid -->
<div style="position:absolute;inset:0;background-image:linear-gradient(to right,rgba(110,90,60,0.05) 1px,transparent 1px),linear-gradient(to bottom,rgba(110,90,60,0.05) 1px,transparent 1px);background-size:{24*scale}px {24*scale}px"></div>
<!-- Design-space inner container at 1× coords, CSS-scaled -->
<div style="position:absolute;inset:0;transform:scale({scale});transform-origin:top left;width:{W}px;height:{H}px">
<!-- Walls -->
<div style="position:absolute;left:{WALLS.x}px;top:{WALLS.y}px;width:{WALLS.w}px;height:{WALLS.h}px;border:2px solid var(--ink-2);border-radius:2px"></div>
<!-- Window -->
<div style="position:absolute;left:{WINDOW.x - 2}px;top:{WINDOW.y}px;width:{WINDOW.w + 4}px;height:{WINDOW.h}px;background:#dfeaf0;border-top:2px solid var(--ink-2);border-bottom:2px solid var(--ink-2)">
<div style="position:absolute;left:1px;top:50%;width:{WINDOW.w + 2}px;height:2px;background:var(--ink-2)"></div>
</div>
<div style="position:absolute;left:-8px;top:{WINDOW.y + WINDOW.h / 2 - 6}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);transform:rotate(-90deg);transform-origin:left top;letter-spacing:0.1em;text-transform:uppercase">Fenster</div>
<!-- Door gap + arc -->
<div style="position:absolute;left:{DOOR.x}px;top:{DOOR.y - 2}px;width:{DOOR.w}px;height:4px;background:#f7f1e3"></div>
<svg style="position:absolute;left:{DOOR.x}px;top:{DOOR.y - 36}px;pointer-events:none" width="{DOOR.w + 10}" height="40">
<path d="M 2 38 Q 2 2 {DOOR.w} 2" stroke="var(--ink-3)" stroke-width="1" stroke-dasharray="2 2" fill="none"/>
<line x1="2" y1="38" x2="2" y2="2" stroke="var(--ink-2)" stroke-width="1.5"/>
</svg>
<div style="position:absolute;left:{DOOR.x + 6}px;top:{DOOR.y + 6}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.1em;text-transform:uppercase">Tür</div>
<!-- Beamer -->
<div style="position:absolute;left:{BEAMER.x}px;top:{BEAMER.y}px;width:{BEAMER.w}px;height:{BEAMER.h}px;background:var(--ink-2);border-radius:1px"></div>
<div style="position:absolute;left:{BEAMER.x + BEAMER.w + 6}px;top:{BEAMER.y}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.1em;text-transform:uppercase">Beamer</div>
<!-- Podium -->
<div style="position:absolute;left:{PODIUM.x}px;top:{PODIUM.y}px;width:{PODIUM.w}px;height:{PODIUM.h}px;border:1.5px solid var(--ink-2);background:#efe6d2;border-radius:2px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:0.15em;text-transform:uppercase">
Pult · Tutor:in
</div>
<!-- Tables -->
{#each TABLES as t (t.id)}
<div style="position:absolute;left:{t.x}px;top:{t.y}px;width:{t.w}px;height:{t.h}px;background:#e8dec5;border:1.5px solid var(--ink-2);border-radius:3px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:22px;color:rgba(31,27,22,0.35);font-style:italic">
{t.label}
</div>
{/each}
<!-- Seats -->
{#each SEATS as seat (seat.id)}
{@const s = seatStyle(seat)}
<button
style="position:absolute;left:{seat.x - 18}px;top:{seat.y - 18}px;width:36px;height:36px;border-radius:50%;background:{s.bg};border:1.5px solid {s.border};cursor:{onSeatClick && variant !== 'student-self' ? 'pointer' : 'default'};display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-weight:600;font-size:11px;color:{s.labelColor};padding:0;box-shadow:{s.shadow};transition:background 120ms,border-color 120ms"
title={assignments[seat.id] ? (studentById(assignments[seat.id])?.name ?? 'besetzt') : 'frei'}
disabled={variant === 'student-self'}
onclick={() => onSeatClick?.(seat)}
>
{s.label}
</button>
{/each}
<!-- Compass -->
<div style="position:absolute;right:18px;bottom:14px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.15em">
<div style="display:flex;flex-direction:column;align-items:center">
<span>N</span>
<svg width="14" height="22">
<path d="M7 2 L7 20 M3 6 L7 2 L11 6" stroke="var(--ink-3)" stroke-width="1" fill="none"/>
</svg>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { auth } from '$lib/auth.svelte';
import { api } from '$lib/api';
import { goto } from '$app/navigation';
const {
activePath,
@@ -40,6 +42,16 @@
function initials(name: string): string {
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
}
async function handleLogout() {
try {
await api.auth.logout();
} catch (e) {
console.error('Logout failed', e);
}
auth.logout();
goto('/admin/login');
}
</script>
<div class="paper-bg" style="width:100%;min-height:100vh;display:grid;grid-template-columns:220px 1fr;overflow:hidden">
@@ -79,6 +91,14 @@
</a>
{/if}
{/each}
<button
onclick={handleLogout}
style="text-align:left;text-decoration:none;background:transparent;padding:8px 10px;border-radius:4px;font-family:var(--sans);font-size:13px;color:var(--ink-2);border:none;cursor:pointer;display:flex;align-items:center;gap:8px;margin-top:12px;border-top:1px solid var(--rule);width:100%"
>
<span style="width:4px;height:4px;border-radius:50%;flex-shrink:0;background:var(--ink-4)"></span>
Abmelden
</button>
</nav>
<div style="flex:1"></div>

View File

@@ -9,6 +9,7 @@ export interface Tutor {
name: string;
email: string;
is_superadmin: boolean;
is_active: boolean;
}
export interface Student {
@@ -50,6 +51,7 @@ export interface Slot {
end_time: string;
status: "closed" | "open" | "locked";
code: string | null;
layout?: LayoutElement[];
}
export interface Attendance {

View File

@@ -12,8 +12,11 @@
let course = $state<Course | null>(null);
const isLoginRoute = $derived($page.url.pathname === '/admin/login');
onMount(async () => {
await auth.init();
if (isLoginRoute) return;
if (!auth.authenticated) {
goto('/admin/login');
return;
@@ -27,7 +30,7 @@
});
$effect(() => {
if (auth.initialized && !auth.authenticated) goto('/admin/login');
if (auth.initialized && !auth.authenticated && !isLoginRoute) goto('/admin/login');
});
const activePath = $derived($page.url.pathname);
@@ -42,6 +45,8 @@
>
{@render children()}
</TutorShell>
{:else if isLoginRoute}
{@render children()}
{:else}
<div style="padding: 2rem; text-align: center;">Redirecting to login...</div>
{/if}

View File

@@ -6,7 +6,7 @@
import Icon from '$lib/components/Icon.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import SeatMap from '$lib/components/SeatMap.svelte';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
import Tally from '$lib/components/Tally.svelte';
@@ -22,6 +22,7 @@
let loading = $state(true);
let pollInterval: ReturnType<typeof setInterval> | null = null;
let loadError = $state<string | null>(null);
onMount(async () => {
await loadData();
@@ -59,6 +60,11 @@
session = sess;
students = await api.admin.courses.listStudents(course.id);
const attendance = await api.admin.sessions.getAttendance(sess.id);
// Instead of just taking the session layout, the slot comes with its own layout from get_session_attendance now
const fullSlot = attendance.slots.find(s => s.id === slotId);
if (fullSlot) {
slot = fullSlot;
}
attendances = (attendance.attendances ?? []).filter((a: Attendance) => a.slot_id === slotId);
notes = await api.admin.slots.getNotes(slotId);
found = true;
@@ -68,7 +74,8 @@
if (found) break;
}
} catch (e) {
console.error(e);
loadError = e instanceof Error ? e.message : 'Daten konnten nicht geladen werden.';
console.error('[live/loadData]', e);
}
}
@@ -101,6 +108,29 @@
const presentCount = $derived(attendances.length);
const absentCount = $derived(students.length - presentCount);
const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[]);
const studentNamesBySeat = $derived((() => {
const map: Record<string, string> = {};
for (const att of attendances) {
if (att.seat_id) {
const student = students.find(s => s.id === att.student_id);
if (student) {
map[att.seat_id] = student.name;
}
}
}
return map;
})());
function handleSeatClick(el: { id: string } | null) {
if (!el) {
selectedStudentId = null;
return;
}
const att = attendances.find(a => a.seat_id === el.id);
selectedStudentId = att ? att.student_id : null;
}
function weekLabel(n: number): string {
return `W${String(n).padStart(2, '0')}`;
@@ -109,6 +139,12 @@
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
{#if loadError}
<div style="background:rgba(138,44,31,0.06);border:1px solid var(--accent);color:var(--accent);padding:10px 14px;border-radius:4px;font-size:13px">
Fehler beim Laden: {loadError}
</div>
{/if}
{#if loading}
<div style="padding:48px;text-align:center">
<span class="small" style="color:var(--ink-4)">Wird geladen…</span>
@@ -158,7 +194,13 @@
</div>
<div class="card" style="overflow:hidden;padding:16px">
<SeatMap variant="tutor" scale={0.78} />
<RoomCanvas
elements={slot.layout ?? []}
clickable={true}
occupiedSeatIds={occupiedSeatIds}
studentNames={studentNamesBySeat}
onElementClick={handleSeatClick}
/>
</div>
<!-- Tally row -->

View File

@@ -5,19 +5,42 @@
let rooms = $state<Room[]>([]);
let newRoomName = $state('');
let errorMsg = $state<string | null>(null);
onMount(async () => {
rooms = await api.admin.rooms.list();
try {
rooms = await api.admin.rooms.list();
} catch (e) {
errorMsg = e instanceof Error ? e.message : 'Räume konnten nicht geladen werden.';
}
});
async function createRoom() {
if (!newRoomName.trim()) return;
errorMsg = null;
try {
await api.admin.rooms.create(newRoomName, []);
newRoomName = '';
} catch (e) {
errorMsg = e instanceof Error ? e.message : 'Raum konnte nicht erstellt werden.';
console.error('[rooms/createRoom]', e);
return;
}
try {
rooms = await api.admin.rooms.list();
} catch {
console.error('failed to fetch rooms');
} catch (e) {
console.error('[rooms/createRoom/list]', e);
}
}
async function deleteRoom(id: number) {
if (!confirm('Raum wirklich löschen?')) return;
errorMsg = null;
try {
await api.admin.rooms.delete(id);
rooms = await api.admin.rooms.list();
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'Raum konnte nicht gelöscht werden.';
}
}
</script>
@@ -28,6 +51,12 @@
<h1 class="h1" style="font-family:var(--serif)">Raumlayout-Editor</h1>
</div>
{#if errorMsg}
<div style="background:rgba(138,44,31,0.06);border:1px solid var(--accent);color:var(--accent);padding:10px 14px;border-radius:4px;font-size:13px;margin-bottom:12px">
{errorMsg}
</div>
{/if}
<div class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
<div class="serif" style="font-size:18px;font-weight:500">Räume</div>
@@ -54,7 +83,10 @@
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">{room.name}</td>
<td style="padding:12px 14px;text-align:right">
<a href="/admin/rooms/{room.id}" class="btn ghost sm">Bearbeiten</a>
<div style="display:flex;justify-content:flex-end;gap:8px">
<a href="/admin/rooms/{room.id}" class="btn ghost sm">Bearbeiten</a>
<button class="btn ghost sm" style="color:var(--accent);border-color:rgba(138,44,31,0.2)" onclick={() => deleteRoom(room.id)}>Löschen</button>
</div>
</td>
</tr>
{/each}

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Room, LayoutElement } from '$lib/types';
@@ -9,38 +8,82 @@
const roomId = $derived(roomIdStr ? parseInt(roomIdStr) : 0);
let room = $state<Room | null>(null);
onMount(async () => {
room = await api.admin.rooms.get(roomId);
});
let errorMsg = $state<string | null>(null);
let snapToGrid = $state(true);
$effect(() => {
if (roomId) {
api.admin.rooms.get(roomId).then((r: Room) => { room = r; });
api.admin.rooms.get(roomId)
.then((r: Room) => { room = r; })
.catch((e: unknown) => {
errorMsg = e instanceof Error ? e.message : 'Raum konnte nicht geladen werden.';
});
}
});
async function saveLayout() {
if (!room) return;
errorMsg = null;
try {
await api.admin.rooms.updateLayout(room.id, room.layout);
} catch {
console.error('failed to save layout');
} catch (e) {
errorMsg = e instanceof Error ? e.message : 'failed to save layout';
}
}
function addElement(type: LayoutElement['type']) {
if (!room) return;
const id = Math.random().toString(36).substr(2, 9);
const id = Math.random().toString(36).slice(2, 11);
let nextLabel: string;
if (type === 'seat') {
const existingLabels = room.layout
.filter((e: LayoutElement) => e.type === 'seat')
.map((e: LayoutElement) => parseInt(e.label, 10))
.filter(n => !isNaN(n));
const maxLabel = existingLabels.length > 0 ? Math.max(...existingLabels) : 0;
nextLabel = (maxLabel + 1).toString();
} else {
nextLabel = '';
}
const newEl: LayoutElement = {
id,
label: type === 'seat' ? (room.layout.filter((e: LayoutElement) => e.type === 'seat').length + 1).toString() : '',
label: nextLabel,
x: 0, y: 0,
width: type === 'table' ? 2 : 1,
height: 1,
type,
};
room.layout = [...room.layout, newEl];
selectedElementId = id;
}
function duplicateElement() {
if (!room || !selectedElement) return;
const id = Math.random().toString(36).slice(2, 11);
let nextLabel: string;
if (selectedElement.type === 'seat') {
const existingLabels = room.layout
.filter((e: LayoutElement) => e.type === 'seat')
.map((e: LayoutElement) => parseInt(e.label, 10))
.filter(n => !isNaN(n));
const maxLabel = existingLabels.length > 0 ? Math.max(...existingLabels) : 0;
nextLabel = (maxLabel + 1).toString();
} else {
nextLabel = selectedElement.label;
}
const duplicatedEl: LayoutElement = {
...selectedElement,
id,
label: nextLabel,
x: selectedElement.x + (snapToGrid ? 0.5 : 0.1),
y: selectedElement.y + (snapToGrid ? 0.5 : 0.1),
};
room.layout = [...room.layout, duplicatedEl];
selectedElementId = id;
}
let selectedElementId = $state<string | null>(null);
@@ -61,20 +104,31 @@
<h2 class="h2" style="font-family:var(--serif)">{room.name}</h2>
</div>
<div style="display:flex;gap:8px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;margin-right:12px;font-size:13px;color:var(--ink-3)">
<input type="checkbox" bind:checked={snapToGrid} /> Raster fangen
</label>
<button class="btn ghost" onclick={() => addElement('seat')}>+ Sitz</button>
<button class="btn ghost" onclick={() => addElement('table')}>+ Tisch</button>
<button class="btn ghost" onclick={() => addElement('door')}>+ Tür</button>
<button class="btn ghost" onclick={() => addElement('gap')}>+ Gap</button>
<button class="btn" onclick={saveLayout}>Speichern</button>
</div>
</div>
{#if errorMsg}
<div style="background:rgba(138,44,31,0.06);border:1px solid var(--accent);color:var(--accent);padding:10px 14px;border-radius:4px;font-size:13px">
{errorMsg}
</div>
{/if}
<div style="display:flex;gap:20px">
<div style="flex:1">
<RoomCanvas
bind:elements={room.layout}
editable={true}
snapStep={snapToGrid ? 0.5 : 0}
selectedId={selectedElementId}
onElementClick={(el) => { selectedElementId = el.id; }}
onElementClick={(el) => { selectedElementId = el ? el.id : null; }}
/>
</div>
@@ -86,6 +140,16 @@
<div class="tiny" style="color:var(--ink-3)">Bezeichnung</div>
<input class="input" bind:value={selectedElement.label} />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<div class="tiny" style="color:var(--ink-3)">X-Pos</div>
<input class="input" type="number" step="0.5" bind:value={selectedElement.x} />
</div>
<div>
<div class="tiny" style="color:var(--ink-3)">Y-Pos</div>
<input class="input" type="number" step="0.5" bind:value={selectedElement.y} />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<div class="tiny" style="color:var(--ink-3)">Breite</div>
@@ -96,6 +160,11 @@
<input class="input" type="number" step="0.5" bind:value={selectedElement.height} />
</div>
</div>
<div style="height:1px;background:var(--rule);margin:8px 0"></div>
<button
class="btn ghost sm" style="justify-content:center"
onclick={duplicateElement}
>Duplizieren</button>
<button
style="color:var(--accent);background:none;border:none;cursor:pointer;text-align:left;font-family:var(--sans);font-size:13px;padding:4px 0"
onclick={deleteElement}

View File

@@ -7,6 +7,7 @@
let tutors = $state<Tutor[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let newTutor = $state({
name: '',
@@ -23,8 +24,10 @@
async function loadTutors() {
try {
tutors = await api.admin.tutors.list();
loadError = null;
} catch (e) {
console.error(e);
loadError = e instanceof Error ? e.message : 'Tutor:innen konnten nicht geladen werden.';
console.error('[tutors/loadTutors]', e);
}
}
@@ -49,6 +52,15 @@
}
}
async function toggleActive(tutor: Tutor) {
try {
await api.admin.tutors.setActive(tutor.id, !tutor.is_active);
await loadTutors();
} catch (err) {
if (err instanceof Error) alert(err.message);
}
}
function initials(name: string): string {
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
}
@@ -75,12 +87,18 @@
<UnderlineStroke width={120} />
</div>
{#if loadError}
<div style="background:rgba(138,44,31,0.06);border:1px solid var(--accent);color:var(--accent);padding:10px 14px;border-radius:4px;font-size:13px;margin-bottom:12px">
{loadError}
</div>
{/if}
<div class="card" style="overflow:hidden">
{#if loading}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Wird geladen…</span>
</div>
{:else if tutors.length === 0}
{:else if tutors.length === 0 && !loadError}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Keine Tutor:innen gefunden.</span>
</div>
@@ -108,18 +126,31 @@
</div>
</td>
<td style="padding:12px 14px">
{#if tutor.is_superadmin}
<span class="pill locked">Superadmin</span>
{:else}
<span class="pill closed">Tutor:in</span>
{/if}
<div style="display:flex;flex-direction:column;gap:4px;align-items:flex-start">
{#if tutor.is_superadmin}
<span class="pill locked">Superadmin</span>
{:else}
<span class="pill closed">Tutor:in</span>
{/if}
{#if !tutor.is_active}
<span class="pill inactive">Inaktiv</span>
{/if}
</div>
</td>
<td style="padding:12px 14px;text-align:right">
<button
class="btn ghost sm"
style="color:var(--accent);border-color:rgba(138,44,31,0.2)"
onclick={() => deleteTutor(tutor.id)}
>Löschen</button>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button
class="btn ghost sm"
onclick={() => toggleActive(tutor)}
>
{tutor.is_active ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button
class="btn ghost sm"
style="color:var(--accent);border-color:rgba(138,44,31,0.2)"
onclick={() => deleteTutor(tutor.id)}
>Löschen</button>
</div>
</td>
</tr>
{/each}

View File

@@ -2,8 +2,8 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import type { Slot, Student, Attendance, CheckinAttendance } from '$lib/types';
import SeatMap from '$lib/components/SeatMap.svelte';
import type { Slot, Student, Attendance, CheckinAttendance, LayoutElement } from '$lib/types';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
const code = $page.params.code as string;
@@ -13,6 +13,7 @@
let errorMsg = $state('');
let slot = $state<Slot | null>(null);
let layout = $state<LayoutElement[]>([]);
let students = $state<Student[]>([]);
let attendances = $state<CheckinAttendance[]>([]);
let myAttendance = $state<Attendance | null>(null);
@@ -42,8 +43,10 @@
async function loadInfo() {
const res = await api.checkin.getInfo(code);
slot = res.slot;
layout = res.layout ?? [];
// We don't have is_mine in the regular Attendance type, so we use CheckinAttendance locally
const checkinAttendances = res.attendances ?? [];
attendances = checkinAttendances;
const mine = checkinAttendances.find((a: CheckinAttendance) => a.is_mine);
if (mine && slot) {
@@ -77,20 +80,24 @@
async function checkin(seatId?: string) {
if (!selectedStudent) return;
errorMsg = '';
try {
const res = await api.checkin.post(code, selectedStudent.id, seatId);
myAttendance = res;
await loadInfo();
await api.checkin.post(code, selectedStudent.id, seatId);
} catch (e) {
if (e instanceof Error) {
if (e.message?.includes('already checked in') || e.message?.includes('409')) {
errorMsg = 'Dieser Platz ist bereits belegt.';
} else {
errorMsg = e.message ?? 'Einchecken fehlgeschlagen.';
}
errorMsg = e.message.includes('seat taken')
? 'Dieser Platz ist bereits belegt.'
: (e.message || 'Einchecken fehlgeschlagen.');
} else {
errorMsg = 'Einchecken fehlgeschlagen.';
errorMsg = 'Einchecken fehlgeschlagen.';
}
return;
}
try {
await loadInfo();
} catch (e) {
step = 'confirmed';
console.error('[checkin/loadInfo]', e);
}
}
@@ -102,6 +109,8 @@
students.filter((s: Student) => s.name.toLowerCase().includes(search.toLowerCase()))
);
const occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(Boolean) as string[]);
function initials(name: string): string {
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
}
@@ -207,10 +216,11 @@
{/if}
<div style="overflow-x:auto">
<SeatMap
variant="student"
scale={0.46}
onSeatClick={(seat) => checkin(seat.id)}
<RoomCanvas
elements={layout}
clickable={true}
occupiedSeatIds={occupiedSeatIds}
onElementClick={(el) => { if (el?.type === 'seat' && !occupiedSeatIds.includes(el.id)) checkin(el.id); }}
/>
</div>
@@ -245,7 +255,11 @@
</div>
<div style="overflow-x:auto">
<SeatMap variant="student-self" scale={0.46} ownSeat={myAttendance?.seat_id ?? null} />
<RoomCanvas
elements={layout}
mySeatId={myAttendance?.seat_id ?? null}
occupiedSeatIds={occupiedSeatIds}
/>
</div>
<button class="btn ghost sm" style="align-self:center" onclick={changeSeat}>
@@ -313,10 +327,11 @@
</div>
{/if}
<SeatMap
variant="student"
scale={0.78}
onSeatClick={(seat) => checkin(seat.id)}
<RoomCanvas
elements={layout}
clickable={true}
occupiedSeatIds={occupiedSeatIds}
onElementClick={(el) => { if (el?.type === 'seat' && !occupiedSeatIds.includes(el.id)) checkin(el.id); }}
/>
<div style="display:flex;gap:20px" class="tiny">
@@ -365,7 +380,11 @@
</div>
</div>
<SeatMap variant="student-self" scale={0.78} ownSeat={myAttendance?.seat_id ?? null} />
<RoomCanvas
elements={layout}
mySeatId={myAttendance?.seat_id ?? null}
occupiedSeatIds={occupiedSeatIds}
/>
<div style="display:flex;gap:10px">
<button class="btn ghost" onclick={changeSeat}>Sitz wechseln</button>

View File

@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test.describe('Login page accessibility', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('renders login form without auth cookies (regression: redirect trap)', async ({ page }) => {
await page.goto('/admin/login');
await expect(page.locator('#email')).toBeVisible();
await expect(page.locator('#password')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
await expect(page.locator('text=Willkommen zurück')).toBeVisible();
await expect(page.locator('text=Redirecting to login')).not.toBeVisible();
});
test('unauthenticated /admin redirects to login form', async ({ page }) => {
await page.goto('/admin');
await page.waitForURL(/\/admin\/login/);
await expect(page.locator('#email')).toBeVisible();
});
});

View File

@@ -13,6 +13,15 @@ if [ "${TT_TEST_PORT_RANDOM:-0}" = "1" ]; then
TT_TEST_PORT=0
fi
# If port is 0 and the backend already wrote its actual port, use it
_port_file="${TT_WORKTREE_ROOT}/data/test/.port"
if [ "$TT_TEST_PORT" = "0" ] && [ -f "$_port_file" ]; then
_real_port=$(cat "$_port_file")
if [ -n "$_real_port" ] && [ "$_real_port" != "0" ]; then
TT_TEST_PORT=$_real_port
fi
fi
TT_TEST_DB="${TT_WORKTREE_ROOT}/data/test/attendance.db"
TT_TEST_MODE=1
TT_BASE_URL="http://127.0.0.1:${TT_TEST_PORT}"