feat: implement active session checks to disable step modifications and add warnings
All checks were successful
Migrate supabase / migrate (push) Successful in 15s
All checks were successful
Migrate supabase / migrate (push) Successful in 15s
This commit is contained in:
@@ -25,7 +25,8 @@
|
||||
submitLabel,
|
||||
errorMessage,
|
||||
initialValues,
|
||||
maxOrder
|
||||
maxOrder,
|
||||
disabled = false
|
||||
}: {
|
||||
gameId: number;
|
||||
gameTitle: string;
|
||||
@@ -35,6 +36,7 @@
|
||||
errorMessage?: string;
|
||||
initialValues: StepFormValues;
|
||||
maxOrder: number;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
const stepTypes = ['question', 'text', 'puzzle', 'location'];
|
||||
@@ -439,7 +441,15 @@
|
||||
<div class="flex gap-4 pt-4">
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
|
||||
40
src/lib/server/gameValidation.ts
Normal file
40
src/lib/server/gameValidation.ts
Normal 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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { escapeGame, step } from '$lib/server/db/schema';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const gameId = parseInt(params.id, 10);
|
||||
@@ -15,7 +16,7 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
where: eq(escapeGame.id, gameId),
|
||||
with: {
|
||||
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');
|
||||
}
|
||||
|
||||
const activeSessionCount = await countActiveIncompleteSessions(gameId);
|
||||
|
||||
return {
|
||||
game
|
||||
game,
|
||||
activeSessionCount
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +40,13 @@ export const actions: Actions = {
|
||||
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 orderRaw = formData.get('order')?.toString() ?? '';
|
||||
|
||||
@@ -100,6 +111,13 @@ export const actions: Actions = {
|
||||
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 stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
|
||||
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
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 reorderPayload = $state('');
|
||||
let reorderForm = $state<HTMLFormElement | undefined>(undefined);
|
||||
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 }) {
|
||||
stepToDelete = step;
|
||||
}
|
||||
@@ -81,37 +86,81 @@
|
||||
</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 -->
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden">
|
||||
<div class="px-8 py-6 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900">Game Steps</h2>
|
||||
{#if data.activeSessionCount === 0}
|
||||
<p class="text-sm text-gray-500">Glissez-deposez pour reordonner</p>
|
||||
{/if}
|
||||
{#if data.activeSessionCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
<form method="POST" action="?/reorderSteps" use:enhance bind:this={reorderForm}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/reorderSteps"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
bind:this={reorderForm}
|
||||
>
|
||||
<input type="hidden" name="order" bind:value={reorderPayload} />
|
||||
</form>
|
||||
|
||||
{#if steps.length > 0}
|
||||
<div class="divide-y divide-gray-200">
|
||||
{#each steps as step (step.id)}
|
||||
<div
|
||||
class="px-8 py-6 hover:bg-gray-50 transition-colors cursor-move"
|
||||
draggable="true"
|
||||
class="px-8 py-6 transition-colors {data.activeSessionCount === 0 ? 'hover:bg-gray-50 cursor-move' : 'cursor-default'}"
|
||||
draggable={data.activeSessionCount === 0}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={`Deplacer l'etape ${step.order}`}
|
||||
ondragstart={() => onDragStart(step.id)}
|
||||
ondragstart={() => data.activeSessionCount === 0 && onDragStart(step.id)}
|
||||
ondragover={onDragOver}
|
||||
ondrop={() => onDrop(step.id)}
|
||||
ondrop={() => data.activeSessionCount === 0 && onDrop(step.id)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
@@ -128,6 +177,30 @@
|
||||
{/if}
|
||||
</div>
|
||||
<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
|
||||
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"
|
||||
@@ -147,6 +220,7 @@
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,10 +245,9 @@
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</form> </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
|
||||
@@ -2,9 +2,10 @@ import { fail, redirect, error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
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 { writeFile } from 'fs/promises';
|
||||
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
|
||||
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
||||
type StepType = (typeof stepTypes)[number];
|
||||
|
||||
@@ -42,10 +43,13 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
.where(eq(step.escapeGameId, gameId))
|
||||
.then((result) => result[0]?.order ?? 0);
|
||||
|
||||
const activeSessionCount = await countActiveIncompleteSessions(gameId);
|
||||
|
||||
return {
|
||||
game,
|
||||
step: gameStep,
|
||||
totalSteps: lastStep
|
||||
totalSteps: lastStep,
|
||||
activeSessionCount
|
||||
};
|
||||
};
|
||||
|
||||
@@ -58,6 +62,13 @@ export const actions: Actions = {
|
||||
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 title = formData.get('title')?.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 {
|
||||
const updated = await db
|
||||
.update(step)
|
||||
|
||||
@@ -35,6 +35,28 @@
|
||||
}));
|
||||
</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
|
||||
gameId={data.game.id}
|
||||
gameTitle={data.game.title}
|
||||
@@ -44,4 +66,5 @@
|
||||
errorMessage={form?.error}
|
||||
{initialValues}
|
||||
maxOrder={data.totalSteps}
|
||||
disabled={data.activeSessionCount > 0}
|
||||
/>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { fail, redirect, error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
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 { writeFile } from 'fs/promises';
|
||||
import { countActiveIncompleteSessions } from '$lib/server/gameValidation';
|
||||
|
||||
const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
|
||||
type StepType = (typeof stepTypes)[number];
|
||||
@@ -36,10 +37,13 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
|
||||
const nextStepOrder = lastStep + 1;
|
||||
|
||||
const activeSessionCount = await countActiveIncompleteSessions(gameId);
|
||||
|
||||
return {
|
||||
game,
|
||||
nextStepOrder,
|
||||
totalSteps: lastStep
|
||||
totalSteps: lastStep,
|
||||
activeSessionCount
|
||||
};
|
||||
};
|
||||
|
||||
@@ -51,6 +55,13 @@ export const actions: Actions = {
|
||||
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 title = formData.get('title')?.toString().trim() ?? '';
|
||||
const description = formData.get('description')?.toString().trim() ?? '';
|
||||
@@ -224,6 +235,22 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
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({
|
||||
escapeGameId: gameId,
|
||||
title,
|
||||
|
||||
@@ -32,6 +32,28 @@
|
||||
}));
|
||||
</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
|
||||
gameId={data.game.id}
|
||||
gameTitle={data.game.title}
|
||||
@@ -41,4 +63,5 @@
|
||||
errorMessage={form?.error}
|
||||
{initialValues}
|
||||
maxOrder={data.nextStepOrder}
|
||||
disabled={data.activeSessionCount > 0}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user