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:
@@ -1,12 +1,14 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth.users import current_active_user
|
from app.auth.users import current_active_user
|
||||||
from app.db import get_async_session
|
from app.db import get_async_session
|
||||||
from app.models.character import Character
|
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.models.user import User
|
||||||
from app.schemas.character import CharacterRead
|
from app.schemas.character import CharacterRead
|
||||||
|
|
||||||
@@ -40,3 +42,30 @@ async def get_character(
|
|||||||
if character.owner_id != user.id:
|
if character.owner_id != user.id:
|
||||||
raise HTTPException(status_code=403, detail="Not your character")
|
raise HTTPException(status_code=403, detail="Not your character")
|
||||||
return 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()
|
||||||
|
|||||||
@@ -140,6 +140,23 @@ async def get_game(
|
|||||||
return serialized[0]
|
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]:
|
async def _serialize_messages(session: AsyncSession, messages: list[Message]) -> list[MessageRead]:
|
||||||
if not messages:
|
if not messages:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ export interface Character {
|
|||||||
export const listCharacters = () => apiFetch<Character[]>("/api/characters");
|
export const listCharacters = () => apiFetch<Character[]>("/api/characters");
|
||||||
|
|
||||||
export const getCharacter = (id: string) => apiFetch<Character>(`/api/characters/${id}`);
|
export const getCharacter = (id: string) => apiFetch<Character>(`/api/characters/${id}`);
|
||||||
|
|
||||||
|
export const deleteCharacter = (id: string) =>
|
||||||
|
apiFetch<void>(`/api/characters/${id}`, { method: "DELETE" });
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export const createGame = (name: string, description: string, adventureText?: st
|
|||||||
|
|
||||||
export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`);
|
export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`);
|
||||||
|
|
||||||
|
export const deleteGame = (id: string) => apiFetch<void>(`/api/games/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
export const joinGame = (id: string, participation_code: string, character_id?: string) =>
|
export const joinGame = (id: string, participation_code: string, character_id?: string) =>
|
||||||
apiFetch<Game>(`/api/games/${id}/join`, {
|
apiFetch<Game>(`/api/games/${id}/join`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,17 +1,60 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Character } from "../api/characters";
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<Link
|
<Link
|
||||||
to={`/characters/${character.id}`}
|
to={`/characters/${character.id}`}
|
||||||
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
||||||
>
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h2 className="text-lg font-semibold text-slate-100">{character.name}</h2>
|
<h2 className="text-lg font-semibold text-slate-100">{character.name}</h2>
|
||||||
|
{isOwner && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="shrink-0 rounded px-2 py-1 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">
|
||||||
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirming}
|
||||||
|
title="Charakter löschen?"
|
||||||
|
message={`„${character.name}" wird endgültig gelöscht. Spiele, in denen der Charakter mitgespielt hat, bleiben erhalten, verlieren aber die Verknüpfung zu ihm.`}
|
||||||
|
confirmLabel="Endgültig löschen"
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
onDelete?.(character.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm rounded-lg border border-slate-700 bg-slate-900 p-5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">{message}</p>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="rounded bg-red-600 px-3 py-2 text-sm font-semibold text-white hover:bg-red-500"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,21 @@ import { useState } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Game } from "../api/games";
|
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 created = new Date(game.created_at).toLocaleString("de-DE");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const isOwner = user?.id === game.creator_id;
|
||||||
|
|
||||||
async function handleCopyLink(e: React.MouseEvent) {
|
async function handleCopyLink(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -16,7 +27,14 @@ export default function GameCard({ game }: { game: Game }) {
|
|||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDeleteClick(e: React.MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirming(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Link
|
<Link
|
||||||
to={`/games/${game.id}`}
|
to={`/games/${game.id}`}
|
||||||
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
||||||
@@ -51,7 +69,7 @@ export default function GameCard({ game }: { game: Game }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
|
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||||
<span>Erstellt: {created}</span>
|
<span>Erstellt: {created}</span>
|
||||||
<span>Von: {game.creator_name}</span>
|
<span>Von: {game.creator_name}</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -59,7 +77,28 @@ export default function GameCard({ game }: { game: Game }) {
|
|||||||
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
|
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
|
||||||
</span>
|
</span>
|
||||||
{game.has_adventure && <span>📖 Eigenes Abenteuer</span>}
|
{game.has_adventure && <span>📖 Eigenes Abenteuer</span>}
|
||||||
|
{isOwner && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="ml-auto rounded px-2 py-1 text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirming}
|
||||||
|
title="Spiel löschen?"
|
||||||
|
message={`„${game.name}" wird endgültig gelöscht, inklusive des gesamten Chatverlaufs. Das kann nicht rückgängig gemacht werden.`}
|
||||||
|
confirmLabel="Endgültig löschen"
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
onDelete?.(game.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Character, listCharacters } from "../api/characters";
|
import { Character, deleteCharacter, listCharacters } from "../api/characters";
|
||||||
import CharacterCard from "../components/CharacterCard";
|
import CharacterCard from "../components/CharacterCard";
|
||||||
|
|
||||||
export default function CharactersList() {
|
export default function CharactersList() {
|
||||||
const [characters, setCharacters] = useState<Character[]>([]);
|
const [characters, setCharacters] = useState<Character[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listCharacters()
|
listCharacters()
|
||||||
@@ -13,9 +14,20 @@ export default function CharactersList() {
|
|||||||
.finally(() => setLoading(false));
|
.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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Charaktere</h1>
|
<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 ? (
|
{loading ? (
|
||||||
<p className="text-slate-400">Lädt…</p>
|
<p className="text-slate-400">Lädt…</p>
|
||||||
) : characters.length === 0 ? (
|
) : characters.length === 0 ? (
|
||||||
@@ -25,7 +37,7 @@ export default function CharactersList() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{characters.map((c) => (
|
{characters.map((c) => (
|
||||||
<CharacterCard key={c.id} character={c} />
|
<CharacterCard key={c.id} character={c} onDelete={handleDelete} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 GameCard from "../components/GameCard";
|
||||||
import GameSetupChat from "../components/GameSetupChat";
|
import GameSetupChat from "../components/GameSetupChat";
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ export default function GamesList() {
|
|||||||
const [games, setGames] = useState<Game[]>([]);
|
const [games, setGames] = useState<Game[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -24,6 +25,16 @@ export default function GamesList() {
|
|||||||
load();
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<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}`)} />}
|
{showCreate && <GameSetupChat onCreated={(game) => navigate(`/games/${game.id}`)} />}
|
||||||
|
|
||||||
|
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-slate-400">Lädt…</p>
|
<p className="text-slate-400">Lädt…</p>
|
||||||
) : games.length === 0 ? (
|
) : games.length === 0 ? (
|
||||||
@@ -47,7 +60,7 @@ export default function GamesList() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{games.map((game) => (
|
{games.map((game) => (
|
||||||
<GameCard key={game.id} game={game} />
|
<GameCard key={game.id} game={game} onDelete={handleDelete} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user