Let owners delete their games and characters, with confirmation

Add DELETE /api/games/{id} (creator-only) and DELETE
/api/characters/{id} (owner-only), each 403ing for anyone else.

Deleting a game relies on the existing cascade FKs (game_participants,
messages, world_state, adventure_chunks all cascade on games.id) — a
plain session.delete() is enough.

Deleting a character is trickier: game_participants.character_id and
messages.character_id reference it with no ON DELETE clause (a
character can outlive a game and vice versa), so deleting one that's
been played would hit a FK violation. Null out both references first
— game and message history stay intact, just detached from the
now-gone character, same as how a participant with no character
selected already renders.

Frontend: a reusable ConfirmDialog component, a "Löschen" button on
GameCard/CharacterCard visible only to the owner (checked against
useAuth()'s user id), wired through GamesList/CharactersList so the
list updates locally after a successful delete.

Verified: isolated backend test proved the character delete's FK
nulling works without violation and preserves the game/message rows;
live in the browser, deleting a game removed it from the list with no
orphaned rows left in any of the four related tables, and the
character delete dialog's cancel path correctly leaves everything
untouched. Ownership scoping confirmed live too — no delete button
appears on other users' games.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-03 12:14:48 +02:00
parent b75cfb1437
commit a58730fce7
9 changed files with 263 additions and 59 deletions
+14 -2
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from "react";
import { Character, listCharacters } from "../api/characters";
import { Character, deleteCharacter, listCharacters } from "../api/characters";
import CharacterCard from "../components/CharacterCard";
export default function CharactersList() {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
listCharacters()
@@ -13,9 +14,20 @@ export default function CharactersList() {
.finally(() => setLoading(false));
}, []);
async function handleDelete(id: string) {
setError(null);
try {
await deleteCharacter(id);
setCharacters((prev) => prev.filter((c) => c.id !== id));
} catch {
setError("Charakter konnte nicht gelöscht werden.");
}
}
return (
<div className="mx-auto max-w-6xl px-4 py-8">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Charaktere</h1>
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
{loading ? (
<p className="text-slate-400">Lädt…</p>
) : characters.length === 0 ? (
@@ -25,7 +37,7 @@ export default function CharactersList() {
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{characters.map((c) => (
<CharacterCard key={c.id} character={c} />
<CharacterCard key={c.id} character={c} onDelete={handleDelete} />
))}
</div>
)}
+15 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Game, listGames } from "../api/games";
import { deleteGame, Game, listGames } from "../api/games";
import GameCard from "../components/GameCard";
import GameSetupChat from "../components/GameSetupChat";
@@ -10,6 +10,7 @@ export default function GamesList() {
const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
@@ -24,6 +25,16 @@ export default function GamesList() {
load();
}, []);
async function handleDelete(id: string) {
setError(null);
try {
await deleteGame(id);
setGames((prev) => prev.filter((g) => g.id !== id));
} catch {
setError("Spiel konnte nicht gelöscht werden.");
}
}
return (
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
@@ -40,6 +51,8 @@ export default function GamesList() {
{showCreate && <GameSetupChat onCreated={(game) => navigate(`/games/${game.id}`)} />}
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
{loading ? (
<p className="text-slate-400">Lädt…</p>
) : games.length === 0 ? (
@@ -47,7 +60,7 @@ export default function GamesList() {
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{games.map((game) => (
<GameCard key={game.id} game={game} />
<GameCard key={game.id} game={game} onDelete={handleDelete} />
))}
</div>
)}