fix: address review findings — error handling, migration safety, CI audit
Some checks failed
CI / test (push) Failing after 3m6s
CI / test (pull_request) Failing after 3m22s

Backend:
- migration 003: apply pixel→grid transform per-element (CASE WHEN > 50)
  instead of per-row, preventing double-conversion of mixed-scale rooms;
  skip empty arrays via json_array_length guard to avoid NULL assignment
- attendance.rs: log layout JSON parse errors instead of silently
  swallowing them with .ok()
- tutors.rs: check rows_affected() in set_tutor_active and return 404
  for non-existent IDs; remap FK constraint errors on delete to 409
  so concurrent inserts between conflict-check and DELETE don't surface
  as 500

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

CI:
- sync cargo audit --ignore RUSTSEC-2023-0071 with Makefile; the advisory
  is in rsa which sqlx-mysql retains in the lock file even when the mysql
  feature is disabled — aws_lc_rs correctly removes it from the active tree
This commit is contained in:
2026-05-05 01:28:40 +02:00
parent 3b9c755e39
commit 827eb63bab
9 changed files with 97 additions and 37 deletions

View File

@@ -77,7 +77,7 @@ jobs:
- name: Security audit
run: |
cargo install cargo-audit --locked
cd backend && cargo audit
cd backend && cargo audit --ignore RUSTSEC-2023-0071
- name: Build frontend
run: pnpm --dir frontend build

View File

@@ -1,24 +1,27 @@
-- Normalize room layout units: divide all coordinates/dimensions by 40 if they are in pixel-scale (e.g. > 50)
-- Note: This is an idempotent approach by only updating rows with large values.
-- Normalize room layout units: divide pixel-scale coordinates by 40.
-- Applied per element with CASE WHEN so the transform is idempotent:
-- coordinates already in grid units (≤50) are left untouched.
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),
'x', CASE WHEN CAST(json_extract(value, '$.x') AS REAL) > 50
THEN ROUND(CAST(json_extract(value, '$.x') AS REAL) / 40.0, 2)
ELSE json_extract(value, '$.x') END,
'y', CASE WHEN CAST(json_extract(value, '$.y') AS REAL) > 50
THEN ROUND(CAST(json_extract(value, '$.y') AS REAL) / 40.0, 2)
ELSE json_extract(value, '$.y') END,
'width', CASE WHEN CAST(json_extract(value, '$.width') AS REAL) > 50
THEN ROUND(CAST(json_extract(value, '$.width') AS REAL) / 40.0, 2)
ELSE json_extract(value, '$.width') END,
'height', CASE WHEN CAST(json_extract(value, '$.height') AS REAL) > 50
THEN ROUND(CAST(json_extract(value, '$.height') AS REAL) / 40.0, 2)
ELSE json_extract(value, '$.height') END,
'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
);
WHERE json_array_length(layout_json) > 0;

View File

@@ -94,7 +94,13 @@ async fn get_session_attendance(
let mut slots = Vec::new();
for row in slot_rows {
let layout: Option<Vec<crate::models::LayoutElement>> = match row.8 {
Some(json_str) => serde_json::from_str(&json_str).ok(),
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 {

View File

@@ -87,12 +87,16 @@ async fn set_tutor_active(
return Err(AppError::Conflict("cannot deactivate yourself".into()));
}
sqlx::query("UPDATE tutors SET is_active = ? WHERE id = ?")
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)
}
@@ -145,10 +149,19 @@ async fn delete_tutor(
)));
}
sqlx::query("DELETE FROM tutors WHERE id = ?")
match sqlx::query("DELETE FROM tutors WHERE id = ?")
.bind(id)
.execute(&pool)
.await?;
.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)),
}
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -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();
@@ -73,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);
}
}
@@ -137,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>

View File

@@ -5,19 +5,31 @@
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);
}
}
@@ -38,6 +50,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>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Room, LayoutElement } from '$lib/types';
@@ -12,13 +11,13 @@
let errorMsg = $state<string | null>(null);
let snapToGrid = $state(true);
onMount(async () => {
room = await api.admin.rooms.get(roomId);
});
$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.';
});
}
});

View File

@@ -7,6 +7,7 @@
let tutors = $state<Tutor[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let newTutor = $state({
name: '',
@@ -23,8 +24,10 @@
async function loadTutors() {
try {
tutors = await api.admin.tutors.list();
loadError = null;
} catch (e) {
console.error(e);
loadError = e instanceof Error ? e.message : 'Tutor:innen konnten nicht geladen werden.';
console.error('[tutors/loadTutors]', e);
}
}
@@ -84,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>

View File

@@ -82,17 +82,21 @@
if (!selectedStudent) return;
try {
await api.checkin.post(code, selectedStudent.id, seatId);
await loadInfo();
} 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);
}
}