Add game cancellation feature with cascade deletion and host-only access control
Implemented DELETE `/games/{game_id}` endpoint allowing hosts to cancel games with cascade deletion of sheet states, chips, and memberships. Added `cancelGame` handler in App.jsx to delete game, refresh game list, reset state, and show confirmation snack. Added cancel button to GamePickerCard lobby section (pre-start) and started section (non-finished games) with bilingual confirmation dialog, loading state, and error handling.
This commit is contained in:
@@ -205,6 +205,22 @@ def start_game(req: Request, game_id: str, db: Session = Depends(get_db)):
|
|||||||
return {"ok": True, "started": True}
|
return {"ok": True, "started": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{game_id}")
|
||||||
|
def cancel_game(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||||
|
uid = require_user(req, db)
|
||||||
|
g = require_game_member(db, game_id, uid)
|
||||||
|
if g.host_user_id != uid:
|
||||||
|
raise HTTPException(403, "only host can cancel the game")
|
||||||
|
|
||||||
|
# Remove dependent game state before deleting the game itself.
|
||||||
|
db.query(SheetState).filter(SheetState.game_id == game_id).delete(synchronize_session=False)
|
||||||
|
db.query(GameChip).filter(GameChip.game_id == game_id).delete(synchronize_session=False)
|
||||||
|
db.query(GameMember).filter(GameMember.game_id == game_id).delete(synchronize_session=False)
|
||||||
|
db.delete(g)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "cancelled": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{game_id}/chips")
|
@router.get("/{game_id}/chips")
|
||||||
def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)):
|
def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||||
uid = require_user(req, db)
|
uid = require_user(req, db)
|
||||||
|
|||||||
@@ -523,6 +523,22 @@ export default function App() {
|
|||||||
await loadGameMeta();
|
await loadGameMeta();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelGame = async () => {
|
||||||
|
if (!gameId || !isHost) return;
|
||||||
|
await api(`/games/${gameId}`, { method: "DELETE" });
|
||||||
|
const nextGames = await api("/games");
|
||||||
|
setGames(nextGames);
|
||||||
|
setGameId(nextGames[0]?.id || null);
|
||||||
|
setSheet(null);
|
||||||
|
setGameMeta(null);
|
||||||
|
setMembers([]);
|
||||||
|
setGameChips([]);
|
||||||
|
setWinnerUserId("");
|
||||||
|
setChipOpen(false);
|
||||||
|
setChipEntry(null);
|
||||||
|
showSnack(language === "en" ? "Game cancelled." : "Spiel abgebrochen.");
|
||||||
|
};
|
||||||
|
|
||||||
// ===== Winner =====
|
// ===== Winner =====
|
||||||
const saveWinner = async () => {
|
const saveWinner = async () => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
@@ -733,6 +749,7 @@ export default function App() {
|
|||||||
winnerName={gameMeta?.winner_display_name || gameMeta?.winner_email || ""}
|
winnerName={gameMeta?.winner_display_name || gameMeta?.winner_email || ""}
|
||||||
chipCount={gameChips.length}
|
chipCount={gameChips.length}
|
||||||
onStartGame={startGame}
|
onStartGame={startGame}
|
||||||
|
onCancelGame={cancelGame}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{gameStarted && (
|
{gameStarted && (
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export default function GamePickerCard({
|
|||||||
winnerName = "",
|
winnerName = "",
|
||||||
chipCount = 0,
|
chipCount = 0,
|
||||||
onStartGame,
|
onStartGame,
|
||||||
|
onCancelGame,
|
||||||
}) {
|
}) {
|
||||||
const { language, t } = useLanguage();
|
const { language, t } = useLanguage();
|
||||||
const cur = games.find((x) => x.id === gameId);
|
const cur = games.find((x) => x.id === gameId);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [startError, setStartError] = useState("");
|
const [startError, setStartError] = useState("");
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
|
||||||
const renderMemberName = (m) => {
|
const renderMemberName = (m) => {
|
||||||
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
|
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
|
||||||
@@ -67,6 +69,21 @@ export default function GamePickerCard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelGame = async () => {
|
||||||
|
if (!onCancelGame || cancelling) return;
|
||||||
|
const confirmed = window.confirm(language === "en" ? "Cancel and delete this game? All game data will be removed." : "Dieses Spiel wirklich abbrechen und löschen? Alle Spieldaten werden entfernt.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
setCancelling(true);
|
||||||
|
setStartError("");
|
||||||
|
try {
|
||||||
|
await onCancelGame();
|
||||||
|
} catch (e) {
|
||||||
|
setStartError(e?.message || (language === "en" ? "The game could not be cancelled." : "Das Spiel konnte nicht abgebrochen werden."));
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
@@ -169,6 +186,9 @@ export default function GamePickerCard({
|
|||||||
<div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}>
|
<div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}>
|
||||||
{members.length < 2 ? t("atLeastTwoPlayers") : t("chipsOnStart")}
|
{members.length < 2 ? t("atLeastTwoPlayers") : t("chipsOnStart")}
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, width: "100%", marginTop: 9, color: "#ffb3b3" }} disabled={cancelling}>
|
||||||
|
{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
|
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
|
||||||
@@ -179,11 +199,14 @@ export default function GamePickerCard({
|
|||||||
{startError && <div style={{ marginTop: 8, color: "#ffb3b3", fontSize: 12 }}>{startError}</div>}
|
{startError && <div style={{ marginTop: 8, color: "#ffb3b3", fontSize: 12 }}>{startError}</div>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div style={{ margin: "0 12px 12px", padding: "10px 12px", borderRadius: 13, border: `1px solid ${stylesTokens.panelBorder}`, background: finished ? "rgba(233,216,166,0.09)" : "rgba(124,255,182,0.07)", color: stylesTokens.textDim, fontSize: 12 }}>
|
<div style={{ margin: "0 12px 12px", padding: "10px 12px", borderRadius: 13, border: `1px solid ${stylesTokens.panelBorder}`, background: finished ? "rgba(233,216,166,0.09)" : "rgba(124,255,182,0.07)", color: stylesTokens.textDim, fontSize: 12 }}>
|
||||||
{finished
|
{finished
|
||||||
? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
|
? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
|
||||||
: `✓ ${t("started")} · ${chipCount} ${t("chipsCreated")}`}
|
: `✓ ${t("started")} · ${chipCount} ${t("chipsCreated")}`}
|
||||||
</div>
|
</div>
|
||||||
|
{isHost && !finished && <button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, margin: "0 12px 12px", width: "calc(100% - 24px)", color: "#ffb3b3" }} disabled={cancelling}>{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}</button>}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spieler */}
|
{/* Spieler */}
|
||||||
|
|||||||
Reference in New Issue
Block a user