Files
Thorsten a58730fce7 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>
2026-09-03 12:14:48 +02:00

72 lines
2.5 KiB
Python

import uuid
from fastapi import APIRouter, Depends, HTTPException
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
router = APIRouter(prefix="/api/characters", tags=["characters"])
@router.get("", response_model=list[CharacterRead])
async def list_my_characters(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[Character]:
characters = (
await session.execute(
select(Character)
.where(Character.owner_id == user.id)
.order_by(Character.created_at.desc())
)
).scalars().all()
return list(characters)
@router.get("/{character_id}", response_model=CharacterRead)
async def get_character(
character_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> Character:
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")
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()