Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f97f91781a | |||
| 99b501d69c | |||
| b7d20d9573 | |||
| 39341ce69d | |||
| ab9d1fc547 | |||
| 681b43174b | |||
| 24f2556c9d | |||
| 4939838a7f | |||
| 827eb63bab | |||
| 3b9c755e39 | |||
| d79c7ed08c | |||
| 650f3456cb |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
11
Makefile
11
Makefile
@@ -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
3047
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
26
backend/migrations/003_normalize_room_layout_units.sql
Normal file
26
backend/migrations/003_normalize_room_layout_units.sql
Normal 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
|
||||
);
|
||||
1
backend/migrations/004_tutor_is_active.sql
Normal file
1
backend/migrations/004_tutor_is_active.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE tutors ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT 1;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ?)"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = ?",
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
40
docs/issues_discovered_20260504.md
Normal file
40
docs/issues_discovered_20260504.md
Normal 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)
|
||||
263
docs/plans/2026-05-04-unified-fixes-and-room-editor-refactor.md
Normal file
263
docs/plans/2026-05-04-unified-fixes-and-room-editor-refactor.md
Normal 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.
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -44,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 = {
|
||||
@@ -96,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' }),
|
||||
@@ -115,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}`),
|
||||
@@ -163,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 })
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user