feat: implement active session checks to disable step modifications and add warnings
All checks were successful
Migrate supabase / migrate (push) Successful in 15s

This commit is contained in:
2026-03-08 22:15:50 +01:00
parent c03c3a4534
commit 477ee716fa
8 changed files with 293 additions and 26 deletions

View File

@@ -25,7 +25,8 @@
submitLabel, submitLabel,
errorMessage, errorMessage,
initialValues, initialValues,
maxOrder maxOrder,
disabled = false
}: { }: {
gameId: number; gameId: number;
gameTitle: string; gameTitle: string;
@@ -35,6 +36,7 @@
errorMessage?: string; errorMessage?: string;
initialValues: StepFormValues; initialValues: StepFormValues;
maxOrder: number; maxOrder: number;
disabled?: boolean;
} = $props(); } = $props();
const stepTypes = ['question', 'text', 'puzzle', 'location']; const stepTypes = ['question', 'text', 'puzzle', 'location'];
@@ -439,7 +441,15 @@
<div class="flex gap-4 pt-4"> <div class="flex gap-4 pt-4">
<button <button
type="submit" type="submit"
class="flex-1 bg-indigo-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors" {disabled}
class="flex-1 py-3 px-6 rounded-lg font-semibold focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors"
class:bg-indigo-600={!disabled}
class:text-white={!disabled}
class:hover:bg-indigo-700={!disabled}
class:focus:ring-indigo-500={!disabled}
class:bg-gray-300={disabled}
class:text-gray-500={disabled}
class:cursor-not-allowed={disabled}
> >
{submitLabel} {submitLabel}
</button> </button>

View File

@@ -0,0 +1,40 @@
import { db } from '$lib/server/db';
import { gameSession } from '$lib/server/db/schema';
import { and, eq, isNull } from 'drizzle-orm';
/**
* Check if a game has any active sessions that are not yet completed
* @param escapeGameId The ID of the escape game to check
* @returns true if there are active incomplete sessions, false otherwise
*/
export async function hasActiveIncompleteSessions(escapeGameId: number): Promise<boolean> {
const activeSessions = await db.query.gameSession.findFirst({
where: and(
eq(gameSession.escapeGameId, escapeGameId),
eq(gameSession.isActive, 1),
isNull(gameSession.completedAt)
)
});
return activeSessions !== undefined;
}
/**
* Get count of active incomplete sessions for a game
* @param escapeGameId The ID of the escape game to check
* @returns Number of active incomplete sessions
*/
export async function countActiveIncompleteSessions(escapeGameId: number): Promise<number> {
const sessions = await db
.select()
.from(gameSession)
.where(
and(
eq(gameSession.escapeGameId, escapeGameId),
eq(gameSession.isActive, 1),
isNull(gameSession.completedAt)
)
);
return sessions.length;
}

View File

@@ -3,6 +3,7 @@ import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { escapeGame, step } from '$lib/server/db/schema'; import { escapeGame, step } from '$lib/server/db/schema';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const gameId = parseInt(params.id, 10); const gameId = parseInt(params.id, 10);
@@ -15,7 +16,7 @@ export const load: PageServerLoad = async ({ params }) => {
where: eq(escapeGame.id, gameId), where: eq(escapeGame.id, gameId),
with: { with: {
steps: { steps: {
orderBy: (steps) => steps.order orderBy: (steps, { asc }) => [asc(steps.order), asc(steps.id)]
} }
} }
}); });
@@ -24,8 +25,11 @@ export const load: PageServerLoad = async ({ params }) => {
error(404, 'Game not found'); error(404, 'Game not found');
} }
const activeSessionCount = await countActiveIncompleteSessions(gameId);
return { return {
game game,
activeSessionCount
}; };
}; };
@@ -36,6 +40,13 @@ export const actions: Actions = {
return fail(400, { error: 'Invalid game ID' }); return fail(400, { error: 'Invalid game ID' });
} }
const hasActiveSessions = await countActiveIncompleteSessions(gameId);
if (hasActiveSessions > 0) {
return fail(400, {
error: `Cannot reorder steps: ${hasActiveSessions} active session(s) in progress. Wait until they are completed or mark them as inactive.`
});
}
const formData = await request.formData(); const formData = await request.formData();
const orderRaw = formData.get('order')?.toString() ?? ''; const orderRaw = formData.get('order')?.toString() ?? '';
@@ -100,6 +111,13 @@ export const actions: Actions = {
return fail(400, { error: 'Invalid game ID' }); return fail(400, { error: 'Invalid game ID' });
} }
const hasActiveSessions = await countActiveIncompleteSessions(gameId);
if (hasActiveSessions > 0) {
return fail(400, {
error: `Cannot delete step: ${hasActiveSessions} active session(s) in progress. Wait until they are completed or mark them as inactive.`
});
}
const formData = await request.formData(); const formData = await request.formData();
const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10); const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);

View File

@@ -6,12 +6,17 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let game = $derived.by(() => data.game); let game = $derived.by(() => data.game);
let steps = $derived([...(game.steps ?? [])]); let steps = $state<typeof data.game.steps>([]);
let draggedStepId = $state<number | null>(null); let draggedStepId = $state<number | null>(null);
let reorderPayload = $state(''); let reorderPayload = $state('');
let reorderForm = $state<HTMLFormElement | undefined>(undefined); let reorderForm = $state<HTMLFormElement | undefined>(undefined);
let stepToDelete = $state<{ id: number; title: string; order: number } | null>(null); let stepToDelete = $state<{ id: number; title: string; order: number } | null>(null);
// Update steps when game data changes
$effect(() => {
steps = [...(game.steps ?? [])];
});
function openDeleteModal(step: { id: number; title: string; order: number }) { function openDeleteModal(step: { id: number; title: string; order: number }) {
stepToDelete = step; stepToDelete = step;
} }
@@ -81,37 +86,81 @@
</div> </div>
</div> </div>
<!-- Active Sessions Warning -->
{#if data.activeSessionCount > 0}
<div class="mb-8">
<div class="bg-amber-50 border-l-4 border-amber-500 rounded-lg p-6">
<div class="flex items-start gap-3">
<svg class="h-6 w-6 text-amber-600 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="flex-1">
<h3 class="text-lg font-semibold text-amber-900 mb-1">⚠️ Editing disabled</h3>
<p class="text-sm text-amber-800 mb-2">
This game has <strong>{data.activeSessionCount}</strong> active session{data.activeSessionCount > 1 ? 's' : ''} in progress.
You cannot add, edit, reorder, or delete steps while sessions are active.
</p>
<p class="text-xs text-amber-700">
Modifying steps would break the progression for active players. Wait for all sessions to complete or mark them as inactive.
</p>
</div>
</div>
</div>
</div>
{/if}
<!-- Steps Section --> <!-- Steps Section -->
<div class="bg-white rounded-xl shadow-md overflow-hidden"> <div class="bg-white rounded-xl shadow-md overflow-hidden">
<div class="px-8 py-6 border-b border-gray-200"> <div class="px-8 py-6 border-b border-gray-200">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900">Game Steps</h2> <h2 class="text-xl font-bold text-gray-900">Game Steps</h2>
<p class="text-sm text-gray-500">Glissez-deposez pour reordonner</p> {#if data.activeSessionCount === 0}
<a <p class="text-sm text-gray-500">Glissez-deposez pour reordonner</p>
href={resolve(`/admin/games/${game.id}/steps/new`)} {/if}
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors" {#if data.activeSessionCount > 0}
> <button
+ Add Step type="button"
</a> disabled
class="bg-gray-300 text-gray-500 px-4 py-2 rounded-lg font-semibold cursor-not-allowed"
title="Cannot add steps while sessions are active"
>
+ Add Step
</button>
{:else}
<a
href={resolve(`/admin/games/${game.id}/steps/new`)}
class="bg-indigo-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
>
+ Add Step
</a>
{/if}
</div> </div>
</div> </div>
<form method="POST" action="?/reorderSteps" use:enhance bind:this={reorderForm}> <form
<input type="hidden" name="order" bind:value={reorderPayload} /> method="POST"
</form> action="?/reorderSteps"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}
bind:this={reorderForm}
>
<input type="hidden" name="order" bind:value={reorderPayload} />
{#if steps.length > 0} {#if steps.length > 0}
<div class="divide-y divide-gray-200"> <div class="divide-y divide-gray-200">
{#each steps as step (step.id)} {#each steps as step (step.id)}
<div <div
class="px-8 py-6 hover:bg-gray-50 transition-colors cursor-move" class="px-8 py-6 transition-colors {data.activeSessionCount === 0 ? 'hover:bg-gray-50 cursor-move' : 'cursor-default'}"
draggable="true" draggable={data.activeSessionCount === 0}
role="button" role="button"
tabindex="0" tabindex="0"
aria-label={`Deplacer l'etape ${step.order}`} aria-label={`Deplacer l'etape ${step.order}`}
ondragstart={() => onDragStart(step.id)} ondragstart={() => data.activeSessionCount === 0 && onDragStart(step.id)}
ondragover={onDragOver} ondragover={onDragOver}
ondrop={() => onDrop(step.id)} ondrop={() => data.activeSessionCount === 0 && onDrop(step.id)}
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
@@ -128,6 +177,30 @@
{/if} {/if}
</div> </div>
<div class="inline-flex items-center gap-2"> <div class="inline-flex items-center gap-2">
{#if data.activeSessionCount > 0}
<button
type="button"
disabled
class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-3 py-1.5 text-gray-400 cursor-not-allowed"
title="Cannot edit while sessions are active"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
type="button"
disabled
class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-3 py-1.5 text-gray-400 cursor-not-allowed"
title="Cannot delete while sessions are active"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
</svg>
Delete
</button>
{:else}
<a <a
href={resolve(`/admin/games/${game.id}/steps/${step.id}`)} href={resolve(`/admin/games/${game.id}/steps/${step.id}`)}
class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100" class="inline-flex items-center gap-1 rounded-md bg-indigo-50 px-3 py-1.5 text-indigo-700 hover:bg-indigo-100"
@@ -147,7 +220,8 @@
</svg> </svg>
Delete Delete
</button> </button>
</div> {/if}
</div>
</div> </div>
</div> </div>
{/each} {/each}
@@ -171,10 +245,9 @@
</a> </a>
</div> </div>
{/if} {/if}
</div> </form> </div>
</div> </div>
</div> </div>
{#if stepToDelete} {#if stepToDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl"> <div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">

View File

@@ -2,9 +2,10 @@ import { fail, redirect, error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { escapeGame, step } from '$lib/server/db/schema'; import { escapeGame, step } from '$lib/server/db/schema';
import { and, eq, max } from 'drizzle-orm'; import { and, eq, max, not } from 'drizzle-orm';
import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads'; import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads';
import { writeFile } from 'fs/promises'; import { writeFile } from 'fs/promises';
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const; const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
type StepType = (typeof stepTypes)[number]; type StepType = (typeof stepTypes)[number];
@@ -42,10 +43,13 @@ export const load: PageServerLoad = async ({ params }) => {
.where(eq(step.escapeGameId, gameId)) .where(eq(step.escapeGameId, gameId))
.then((result) => result[0]?.order ?? 0); .then((result) => result[0]?.order ?? 0);
const activeSessionCount = await countActiveIncompleteSessions(gameId);
return { return {
game, game,
step: gameStep, step: gameStep,
totalSteps: lastStep totalSteps: lastStep,
activeSessionCount
}; };
}; };
@@ -58,6 +62,13 @@ export const actions: Actions = {
return fail(400, { error: 'Invalid ID' }); return fail(400, { error: 'Invalid ID' });
} }
const hasActiveSessions = await countActiveIncompleteSessions(gameId);
if (hasActiveSessions > 0) {
return fail(400, {
error: `Cannot modify step: ${hasActiveSessions} active session(s) in progress. Wait until they are completed or mark them as inactive.`
});
}
const formData = await request.formData(); const formData = await request.formData();
const title = formData.get('title')?.toString().trim() ?? ''; const title = formData.get('title')?.toString().trim() ?? '';
const description = formData.get('description')?.toString().trim() ?? ''; const description = formData.get('description')?.toString().trim() ?? '';
@@ -237,6 +248,48 @@ export const actions: Actions = {
}); });
} }
// Get the current step to check if order changed
const currentStep = await db.query.step.findFirst({
where: and(eq(step.id, stepId), eq(step.escapeGameId, gameId))
});
if (!currentStep) {
return fail(404, {
title,
description,
type,
content,
answer,
hint,
order,
error: 'Etape introuvable'
});
}
// Check if order changed and if there's a conflict
if (currentStep.order !== order) {
const conflictingStep = await db.query.step.findFirst({
where: and(
eq(step.escapeGameId, gameId),
eq(step.order, order),
not(eq(step.id, stepId))
)
});
if (conflictingStep) {
return fail(400, {
title,
description,
type,
content,
answer,
hint,
order,
error: `Une autre etape utilise deja l'ordre ${order}. Veuillez reordonner les etapes via drag-and-drop.`
});
}
}
try { try {
const updated = await db const updated = await db
.update(step) .update(step)

View File

@@ -35,6 +35,28 @@
})); }));
</script> </script>
{#if data.activeSessionCount > 0}
<div class="max-w-4xl mx-auto px-4 pt-8">
<div class="bg-red-50 border-l-4 border-red-500 rounded-lg p-6 mb-6">
<div class="flex items-start gap-3">
<svg class="h-6 w-6 text-red-600 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="flex-1">
<h3 class="text-lg font-semibold text-red-900 mb-1">🚫 Cannot edit step</h3>
<p class="text-sm text-red-800 mb-2">
This game has <strong>{data.activeSessionCount}</strong> active session{data.activeSessionCount > 1 ? 's' : ''} in progress.
Editing steps would break the progression for active players.
</p>
<p class="text-xs text-red-700">
Wait for all sessions to complete or mark them as inactive before modifying this step.
</p>
</div>
</div>
</div>
</div>
{/if}
<StepForm <StepForm
gameId={data.game.id} gameId={data.game.id}
gameTitle={data.game.title} gameTitle={data.game.title}
@@ -44,4 +66,5 @@
errorMessage={form?.error} errorMessage={form?.error}
{initialValues} {initialValues}
maxOrder={data.totalSteps} maxOrder={data.totalSteps}
disabled={data.activeSessionCount > 0}
/> />

View File

@@ -2,9 +2,10 @@ import { fail, redirect, error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { escapeGame, step } from '$lib/server/db/schema'; import { escapeGame, step } from '$lib/server/db/schema';
import { eq, max } from 'drizzle-orm'; import { and, eq, gte, max } from 'drizzle-orm';
import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads'; import { ensureUploadDir, generateUniqueFilename, getUploadPath } from '$lib/server/uploads';
import { writeFile } from 'fs/promises'; import { writeFile } from 'fs/promises';
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const; const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
type StepType = (typeof stepTypes)[number]; type StepType = (typeof stepTypes)[number];
@@ -36,10 +37,13 @@ export const load: PageServerLoad = async ({ params }) => {
const nextStepOrder = lastStep + 1; const nextStepOrder = lastStep + 1;
const activeSessionCount = await countActiveIncompleteSessions(gameId);
return { return {
game, game,
nextStepOrder, nextStepOrder,
totalSteps: lastStep totalSteps: lastStep,
activeSessionCount
}; };
}; };
@@ -51,6 +55,13 @@ export const actions: Actions = {
return fail(400, { error: 'Invalid game ID' }); return fail(400, { error: 'Invalid game ID' });
} }
const hasActiveSessions = await countActiveIncompleteSessions(gameId);
if (hasActiveSessions > 0) {
return fail(400, {
error: `Cannot add step: ${hasActiveSessions} active session(s) in progress. Wait until they are completed or mark them as inactive.`
});
}
const formData = await request.formData(); const formData = await request.formData();
const title = formData.get('title')?.toString().trim() ?? ''; const title = formData.get('title')?.toString().trim() ?? '';
const description = formData.get('description')?.toString().trim() ?? ''; const description = formData.get('description')?.toString().trim() ?? '';
@@ -224,6 +235,22 @@ export const actions: Actions = {
} }
try { try {
// If inserting in the middle, shift existing steps
if (order <= lastStep) {
const stepsToShift = await db
.select({ id: step.id, order: step.order })
.from(step)
.where(and(eq(step.escapeGameId, gameId), gte(step.order, order)));
// Shift all steps >= order by 1
for (const s of stepsToShift) {
await db
.update(step)
.set({ order: s.order + 1 })
.where(eq(step.id, s.id));
}
}
await db.insert(step).values({ await db.insert(step).values({
escapeGameId: gameId, escapeGameId: gameId,
title, title,

View File

@@ -32,6 +32,28 @@
})); }));
</script> </script>
{#if data.activeSessionCount > 0}
<div class="max-w-4xl mx-auto px-4 pt-8">
<div class="bg-red-50 border-l-4 border-red-500 rounded-lg p-6 mb-6">
<div class="flex items-start gap-3">
<svg class="h-6 w-6 text-red-600 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="flex-1">
<h3 class="text-lg font-semibold text-red-900 mb-1">🚫 Cannot add step</h3>
<p class="text-sm text-red-800 mb-2">
This game has <strong>{data.activeSessionCount}</strong> active session{data.activeSessionCount > 1 ? 's' : ''} in progress.
Adding steps would break the progression for active players.
</p>
<p class="text-xs text-red-700">
Wait for all sessions to complete or mark them as inactive before adding new steps.
</p>
</div>
</div>
</div>
</div>
{/if}
<StepForm <StepForm
gameId={data.game.id} gameId={data.game.id}
gameTitle={data.game.title} gameTitle={data.game.title}
@@ -41,4 +63,5 @@
errorMessage={form?.error} errorMessage={form?.error}
{initialValues} {initialValues}
maxOrder={data.nextStepOrder} maxOrder={data.nextStepOrder}
disabled={data.activeSessionCount > 0}
/> />