From fd6beb4591c5d3a5eca1b08ffbfde2c11deb4672 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Wed, 6 May 2026 16:09:08 +0200 Subject: [PATCH] fix: superadmin access for sessions/rooms; redesign room editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - sessions.rs: add is_superadmin bypass to list_sessions, create_session, create_slot, update_slot_status, delete_slot - rooms.rs: allow empty layout on create_room (layout is built in editor after creation) Frontend room editor: - Fix drag coordinate sync: replace window mousemove delta calc with SVG CTM inverse transform (getScreenCTM), eliminating the 3x movement ratio bug - Switch to pointer capture (setPointerCapture) per element; remove svelte:window handlers - Positions always snap to 0.5 grid on drop, preventing backend validation errors - Left floating sidebar toolbar (collapsible) with Sitz/Tisch/ Tür/Lücke buttons and inline SVG icons - Room dimensions bar (Breite × Tiefe, 5–50 grid units) - Zoom controls (25%–400%) via scroll wheel or +/- buttons; SVG scales via width/height attrs so scrollbars work correctly - Property panel inputs snap to 0.5 on blur (prevents save errors) - Canvas bounded to room dimensions during drag --- backend/src/routes/rooms.rs | 4 +- backend/src/routes/sessions.rs | 50 +- frontend/src/lib/RoomCanvas.svelte | 492 ++++++++-------- .../routes/admin/rooms/[roomId]/+page.svelte | 544 ++++++++++++++---- 4 files changed, 717 insertions(+), 373 deletions(-) diff --git a/backend/src/routes/rooms.rs b/backend/src/routes/rooms.rs index 29a0779..e19d723 100644 --- a/backend/src/routes/rooms.rs +++ b/backend/src/routes/rooms.rs @@ -104,7 +104,9 @@ async fn create_room( State(pool): State, Json(req): Json, ) -> Result<(StatusCode, Json), AppError> { - validate_layout(&req.layout)?; + if !req.layout.is_empty() { + validate_layout(&req.layout)?; + } let layout_json = serde_json::to_string(&req.layout) .map_err(|e| AppError::BadRequest(format!("layout serialization error: {e}")))?; let id = sqlx::query("INSERT INTO rooms (name, layout_json) VALUES (?, ?)") diff --git a/backend/src/routes/sessions.rs b/backend/src/routes/sessions.rs index bdaf338..da730fd 100644 --- a/backend/src/routes/sessions.rs +++ b/backend/src/routes/sessions.rs @@ -33,7 +33,9 @@ async fn list_sessions( State(pool): State, Query(q): Query, ) -> Result, AppError> { - super::verify_tutor_course_access(&pool, claims.sub, q.course_id).await?; + if !claims.is_superadmin { + super::verify_tutor_course_access(&pool, claims.sub, q.course_id).await?; + } let sessions = sqlx::query_as::<_, Session>( "SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY date, week_nr", @@ -74,7 +76,9 @@ async fn create_session( chrono::NaiveDate::parse_from_str(&req.date, "%Y-%m-%d") .map_err(|_| AppError::BadRequest("date must be YYYY-MM-DD".into()))?; - super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?; + if !claims.is_superadmin { + super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?; + } let id = sqlx::query("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)") .bind(req.course_id) @@ -105,23 +109,25 @@ async fn create_slot( .await?; let (course_id,) = session_row.ok_or(AppError::NotFound)?; - // Verify requesting tutor has access to the course - super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + if !claims.is_superadmin { + // 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 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:in ist in diesem Kurs nicht aktiv oder existiert nicht.".into(), - )); + // 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:in ist in diesem Kurs nicht aktiv oder existiert nicht.".into(), + )); + } } // Optionally verify room exists @@ -183,7 +189,9 @@ async fn update_slot_status( .await?; let (course_id, existing_code) = row.ok_or(AppError::NotFound)?; - super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + if !claims.is_superadmin { + super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + } // If transitioning to open and no code yet, generate one (retry on UNIQUE collision) let code: Option = if req.status == "open" { @@ -262,7 +270,9 @@ async fn delete_slot( )); } - super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + if !claims.is_superadmin { + super::verify_tutor_course_access(&pool, claims.sub, course_id).await?; + } sqlx::query("DELETE FROM slots WHERE id = ?") .bind(slot_id) diff --git a/frontend/src/lib/RoomCanvas.svelte b/frontend/src/lib/RoomCanvas.svelte index 8314b75..b713562 100644 --- a/frontend/src/lib/RoomCanvas.svelte +++ b/frontend/src/lib/RoomCanvas.svelte @@ -11,253 +11,255 @@ selectedId?: string | null; occupiedSeatIds?: string[]; mySeatId?: string | null; - studentNames?: Record; // seat_id -> name + studentNames?: Record; + roomWidth?: number; + roomHeight?: number; + zoom?: number; } - let { - elements = $bindable([]), - editable = false, + let { + elements = $bindable([]), + editable = false, clickable = false, snapStep = 0.5, - onElementClick, + onElementClick, onLayoutChange, selectedId = null, occupiedSeatIds = [], mySeatId = null, - studentNames = {} + studentNames = {}, + roomWidth = 20, + roomHeight = 15, + zoom = 1.0, }: Props = $props(); + const GRID_SIZE = 40; + + let svgEl: SVGSVGElement; + + function toGridCoords(e: PointerEvent): { x: number; y: number } { + const ctm = svgEl.getScreenCTM(); + if (!ctm) return { x: 0, y: 0 }; + const pt = svgEl.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const svgPt = pt.matrixTransform(ctm.inverse()); + return { x: svgPt.x / GRID_SIZE, y: svgPt.y / GRID_SIZE }; + } + + function snapVal(v: number): number { + if (snapStep <= 0) return v; + return Math.round(v / snapStep) * snapStep; + } + let draggingId = $state(null); let resizingId = $state(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 startGrid = { x: 0, y: 0 }; + let initEl = { x: 0, y: 0, w: 0, h: 0 }; let dragMoved = false; - const GRID_SIZE = 40; - - function handleMouseDown(e: MouseEvent, el: LayoutElement) { - if (clickable && !editable) { - onElementClick?.(el); - return; - } + function onElemPointerDown(e: PointerEvent, el: LayoutElement) { + if (clickable && !editable) { onElementClick?.(el); return; } if (!editable) return; - + e.stopPropagation(); + (e.currentTarget as Element).setPointerCapture(e.pointerId); draggingId = el.id; dragMoved = false; - startX = e.clientX; - startY = e.clientY; - initialX = el.x; - initialY = el.y; - e.stopPropagation(); + startGrid = toGridCoords(e); + initEl = { x: el.x, y: el.y, w: el.width, h: el.height }; } - 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 onElemPointerMove(e: PointerEvent, el: LayoutElement) { + if (!editable || draggingId !== el.id) return; + const cur = toGridCoords(e); + const dx = cur.x - startGrid.x; + const dy = cur.y - startGrid.y; + if (Math.abs(dx) > 0.05 || Math.abs(dy) > 0.05) dragMoved = true; + const i = elements.findIndex(x => x.id === el.id); + if (i === -1) return; + const el2 = elements[i]!; + const newX = Math.max(0, Math.min(roomWidth - el2.width, snapVal(initEl.x + dx))); + const newY = Math.max(0, Math.min(roomHeight - el2.height, snapVal(initEl.y + dy))); + elements[i] = { ...el2, x: newX, y: newY }; } - 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); - } + function onElemPointerUp(e: PointerEvent, el: LayoutElement) { + if (!editable || draggingId !== el.id) return; + if (dragMoved) { + onLayoutChange?.(elements); + } else { + onElementClick?.(el); } draggingId = null; - resizingId = null; - resizeDir = null; dragMoved = false; } - // 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)}`); + function onResizeDown(e: PointerEvent, el: LayoutElement, dir: 'e' | 's' | 'se') { + if (!editable) return; + e.stopPropagation(); + (e.currentTarget as Element).setPointerCapture(e.pointerId); + resizingId = el.id; + resizeDir = dir; + startGrid = toGridCoords(e); + initEl = { x: el.x, y: el.y, w: el.width, h: el.height }; + } + + function onResizeMove(e: PointerEvent, el: LayoutElement) { + if (!editable || resizingId !== el.id || !resizeDir) return; + const cur = toGridCoords(e); + const dx = cur.x - startGrid.x; + const dy = cur.y - startGrid.y; + const minSz = snapStep > 0 ? snapStep : 0.5; + const i = elements.findIndex(x => x.id === el.id); + if (i === -1) return; + const el2 = elements[i]!; + let newW = el2.width; + let newH = el2.height; + if (resizeDir.includes('e')) newW = snapVal(Math.max(minSz, initEl.w + dx)); + if (resizeDir.includes('s')) newH = snapVal(Math.max(minSz, initEl.h + dy)); + elements[i] = { ...el2, width: newW, height: newH }; + } + + function onResizeUp() { + if (resizingId) onLayoutChange?.(elements); + resizingId = null; + resizeDir = null; + } + + const viewBox = $derived(`0 0 ${roomWidth * GRID_SIZE} ${roomHeight * GRID_SIZE}`); + const svgWidth = $derived(roomWidth * GRID_SIZE * zoom); + const svgHeight = $derived(roomHeight * GRID_SIZE * zoom); - - - - { - // Deselect if clicking on empty canvas - if (editable && e.target === e.currentTarget) { - onElementClick?.(null); - } + onpointerdown={(e) => { + if (editable && e.target === e.currentTarget) onElementClick?.(null); }} > + + + {#if editable && snapStep > 0} - - + + - + {/if} {#each elements as el (el.id)} - + handleMouseDown(e, el)} + onpointerdown={(e) => onElemPointerDown(e, el)} + onpointermove={(e) => onElemPointerMove(e, el)} + onpointerup={(e) => onElemPointerUp(e, el)} > {#if el.type === 'seat'} - - + - {el.label} - + >{el.label} {#if studentNames[el.id]} - - {studentNames[el.id]} - + >{studentNames[el.id]} {/if} {:else if el.type === 'table'} - + + {#if editable} + TISCH + {/if} {:else if el.type === 'gap'} - - {#if editable} - - GAP - + class="gap-label" + >LÜCKE {/if} {:else if el.type === 'door'} - - + - DOOR - + class="door-label" + >TÜR {/if} {#if editable && selectedId === el.id} - handleResizeDown(e, el, 'e')} /> - handleResizeDown(e, el, 's')} /> - handleResizeDown(e, el, 'se')} /> + + onResizeDown(e, el, 'e')} + onpointermove={(e) => onResizeMove(e, el)} + onpointerup={onResizeUp} + /> + + onResizeDown(e, el, 's')} + onpointermove={(e) => onResizeMove(e, el)} + onpointerup={onResizeUp} + /> + + onResizeDown(e, el, 'se')} + onpointermove={(e) => onResizeMove(e, el)} + onpointerup={onResizeUp} + /> {/if} {/each} @@ -265,68 +267,96 @@
-
-
- Räume -

{room.name}

-
-
- - - - - - -
+
+ + +
+
+ ← Räume + / + {room.name}
- - {#if errorMsg} -
- {errorMsg} -
- {/if} +
+ + {#if saveOk} + ✓ Gespeichert + {/if} + +
+
-
-
- { selectedElementId = el ? el.id : null; }} - /> -
+ +
+ Raumgröße + + × + + Rasterfelder +
-
-
Auswahl
- {#if selectedElement} -
-
-
Bezeichnung
- -
-
-
-
X-Pos
- -
-
-
Y-Pos
- -
-
-
-
-
Breite
- -
-
-
Höhe
- -
-
-
- - + {#if errorMsg} +
{errorMsg}
+ {/if} + + +
+ + +
+ + +
+ + + {#if sidebarOpen} +
+ + + +
- {:else} - Element auswählen {/if}
+ + +
+ + + +
+ + +
+
+ { selectedElementId = el ? el.id : null; }} + /> +
+
+ + + +
+
+ +{:else if errorMsg} +
+
{errorMsg}
{:else}
Raum wird geladen…
{/if} + +