From a58730fce7593e04fdeda32612533781b6733e0c Mon Sep 17 00:00:00 2001 From: Thorsten Date: Thu, 3 Sep 2026 12:14:48 +0200 Subject: [PATCH] Let owners delete their games and characters, with confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/routes_characters.py | 31 +++++- backend/app/api/routes_games.py | 17 +++ frontend/src/api/characters.ts | 3 + frontend/src/api/games.ts | 2 + frontend/src/components/CharacterCard.tsx | 63 +++++++++-- frontend/src/components/ConfirmDialog.tsx | 46 ++++++++ frontend/src/components/GameCard.tsx | 127 ++++++++++++++-------- frontend/src/pages/CharactersList.tsx | 16 ++- frontend/src/pages/GamesList.tsx | 17 ++- 9 files changed, 263 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/ConfirmDialog.tsx diff --git a/backend/app/api/routes_characters.py b/backend/app/api/routes_characters.py index 2e60677..315fdb9 100644 --- a/backend/app/api/routes_characters.py +++ b/backend/app/api/routes_characters.py @@ -1,12 +1,14 @@ import uuid from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.auth.users import current_active_user from app.db import get_async_session from app.models.character import Character +from app.models.game import GameParticipant +from app.models.message import Message from app.models.user import User from app.schemas.character import CharacterRead @@ -40,3 +42,30 @@ async def get_character( if character.owner_id != user.id: raise HTTPException(status_code=403, detail="Not your character") return character + + +@router.delete("/{character_id}", status_code=204) +async def delete_character( + character_id: uuid.UUID, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +) -> None: + character = await session.get(Character, character_id) + if character is None: + raise HTTPException(status_code=404, detail="Character not found") + if character.owner_id != user.id: + raise HTTPException(status_code=403, detail="Not your character") + + # game_participants.character_id and messages.character_id have no ON DELETE cascade + # (a character can outlive its games and vice versa) — detach them first so the delete + # doesn't hit a FK violation. Games and message history stay intact either way. + await session.execute( + update(GameParticipant) + .where(GameParticipant.character_id == character_id) + .values(character_id=None) + ) + await session.execute( + update(Message).where(Message.character_id == character_id).values(character_id=None) + ) + await session.delete(character) + await session.commit() diff --git a/backend/app/api/routes_games.py b/backend/app/api/routes_games.py index 42dd132..4346ac3 100644 --- a/backend/app/api/routes_games.py +++ b/backend/app/api/routes_games.py @@ -140,6 +140,23 @@ async def get_game( return serialized[0] +@router.delete("/{game_id}", status_code=204) +async def delete_game( + game_id: uuid.UUID, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +) -> None: + game = await session.get(Game, game_id) + if game is None: + raise HTTPException(status_code=404, detail="Game not found") + if game.creator_id != user.id: + raise HTTPException(status_code=403, detail="Only the creator can delete this game") + + # game_participants, messages, world_state, and adventure_chunks all cascade on games.id. + await session.delete(game) + await session.commit() + + async def _serialize_messages(session: AsyncSession, messages: list[Message]) -> list[MessageRead]: if not messages: return [] diff --git a/frontend/src/api/characters.ts b/frontend/src/api/characters.ts index 93039cf..48428f3 100644 --- a/frontend/src/api/characters.ts +++ b/frontend/src/api/characters.ts @@ -26,3 +26,6 @@ export interface Character { export const listCharacters = () => apiFetch("/api/characters"); export const getCharacter = (id: string) => apiFetch(`/api/characters/${id}`); + +export const deleteCharacter = (id: string) => + apiFetch(`/api/characters/${id}`, { method: "DELETE" }); diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index c6044ff..cbd9f70 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -60,6 +60,8 @@ export const createGame = (name: string, description: string, adventureText?: st export const getGame = (id: string) => apiFetch(`/api/games/${id}`); +export const deleteGame = (id: string) => apiFetch(`/api/games/${id}`, { method: "DELETE" }); + export const joinGame = (id: string, participation_code: string, character_id?: string) => apiFetch(`/api/games/${id}/join`, { method: "POST", diff --git a/frontend/src/components/CharacterCard.tsx b/frontend/src/components/CharacterCard.tsx index 1520133..b197787 100644 --- a/frontend/src/components/CharacterCard.tsx +++ b/frontend/src/components/CharacterCard.tsx @@ -1,17 +1,60 @@ +import { useState } from "react"; import { Link } from "react-router-dom"; import { Character } from "../api/characters"; +import { useAuth } from "../hooks/useAuth"; +import ConfirmDialog from "./ConfirmDialog"; + +export default function CharacterCard({ + character, + onDelete, +}: { + character: Character; + onDelete?: (id: string) => void; +}) { + const { user } = useAuth(); + const [confirming, setConfirming] = useState(false); + const isOwner = user?.id === character.owner_id; + + function handleDeleteClick(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + setConfirming(true); + } -export default function CharacterCard({ character }: { character: Character }) { return ( - -

{character.name}

-

- {character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level} -

- + <> + +
+

{character.name}

+ {isOwner && onDelete && ( + + )} +
+

+ {character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level} +

+ + + setConfirming(false)} + onConfirm={() => { + setConfirming(false); + onDelete?.(character.id); + }} + /> + ); } diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..7209120 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,46 @@ +export default function ConfirmDialog({ + open, + title, + message, + confirmLabel = "Löschen", + onConfirm, + onCancel, +}: { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + onConfirm: () => void; + onCancel: () => void; +}) { + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/GameCard.tsx b/frontend/src/components/GameCard.tsx index 72955dc..fab98c4 100644 --- a/frontend/src/components/GameCard.tsx +++ b/frontend/src/components/GameCard.tsx @@ -2,10 +2,21 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { Game } from "../api/games"; +import { useAuth } from "../hooks/useAuth"; +import ConfirmDialog from "./ConfirmDialog"; -export default function GameCard({ game }: { game: Game }) { +export default function GameCard({ + game, + onDelete, +}: { + game: Game; + onDelete?: (id: string) => void; +}) { + const { user } = useAuth(); const created = new Date(game.created_at).toLocaleString("de-DE"); const [copied, setCopied] = useState(false); + const [confirming, setConfirming] = useState(false); + const isOwner = user?.id === game.creator_id; async function handleCopyLink(e: React.MouseEvent) { e.preventDefault(); @@ -16,50 +27,78 @@ export default function GameCard({ game }: { game: Game }) { setTimeout(() => setCopied(false), 1500); } + function handleDeleteClick(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + setConfirming(true); + } + return ( - -
-

{game.name}

- {game.status === "ended" && ( - - Beendet - - )} - {game.participation_code && ( - - )} -
-

{game.description}

-
- Erstellt: {created} - Von: {game.creator_name} - - {game.player_count} Spieler - {game.player_names.length > 0 && `: ${game.player_names.join(", ")}`} - - {game.has_adventure && 📖 Eigenes Abenteuer} -
- + Code: {game.participation_code} + + + + + {copied && Kopiert!} + + )} + +

{game.description}

+
+ Erstellt: {created} + Von: {game.creator_name} + + {game.player_count} Spieler + {game.player_names.length > 0 && `: ${game.player_names.join(", ")}`} + + {game.has_adventure && 📖 Eigenes Abenteuer} + {isOwner && onDelete && ( + + )} +
+ + + setConfirming(false)} + onConfirm={() => { + setConfirming(false); + onDelete?.(game.id); + }} + /> + ); } diff --git a/frontend/src/pages/CharactersList.tsx b/frontend/src/pages/CharactersList.tsx index 2b3b24d..5eb3a9c 100644 --- a/frontend/src/pages/CharactersList.tsx +++ b/frontend/src/pages/CharactersList.tsx @@ -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([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 (

Charaktere

+ {error &&

{error}

} {loading ? (

Lädt…

) : characters.length === 0 ? ( @@ -25,7 +37,7 @@ export default function CharactersList() { ) : (
{characters.map((c) => ( - + ))}
)} diff --git a/frontend/src/pages/GamesList.tsx b/frontend/src/pages/GamesList.tsx index 3e6dc48..a1b2d60 100644 --- a/frontend/src/pages/GamesList.tsx +++ b/frontend/src/pages/GamesList.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); + const [error, setError] = useState(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 (
@@ -40,6 +51,8 @@ export default function GamesList() { {showCreate && navigate(`/games/${game.id}`)} />} + {error &&

{error}

} + {loading ? (

Lädt…

) : games.length === 0 ? ( @@ -47,7 +60,7 @@ export default function GamesList() { ) : (
{games.map((game) => ( - + ))}
)}