feat: game history + question lang/active management
DB:
- quiz_question gains `lang` ('fr' default) and `active` (true default); migration 0002
- only active questions are served to games (pool filter)
History:
- engine writes a game_history row at game end (modes + ranked results), fire-and-forget
- public GET /api/history/:code lists a room's recent games
- lobby shows a "Parties précédentes" collapsible (date, modes, winner/scores)
Back-office:
- create form has a FR/EN language select; lang stored
- per-question active toggle (PATCH /questions/:id) — disable without deleting;
inactive rows dimmed and labelled
- seeds set lang (opentdb=en, manual/pokemon=fr)
Verified e2e: migration applied; game played → /api/history returns it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ed1747543b
commit
b3efa27287
15 changed files with 530 additions and 6 deletions
2
apps/server/drizzle/0002_lowly_tenebrous.sql
Normal file
2
apps/server/drizzle/0002_lowly_tenebrous.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "quiz_question" ADD COLUMN "lang" text DEFAULT 'fr' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "quiz_question" ADD COLUMN "active" boolean DEFAULT true NOT NULL;
|
||||||
302
apps/server/drizzle/meta/0002_snapshot.json
Normal file
302
apps/server/drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
{
|
||||||
|
"id": "7df3d3d1-dbf0-445e-a5c1-5ef4872a80de",
|
||||||
|
"prevId": "6de8c2ee-3634-455b-ba4b-372368fc2e66",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.game_history": {
|
||||||
|
"name": "game_history",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"room_code": {
|
||||||
|
"name": "room_code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"played_at": {
|
||||||
|
"name": "played_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"name": "modes",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"name": "results",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.quiz_category": {
|
||||||
|
"name": "quiz_category",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"quiz_category_name_unique": {
|
||||||
|
"name": "quiz_category_name_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.quiz_played": {
|
||||||
|
"name": "quiz_played",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"question_id": {
|
||||||
|
"name": "question_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"room_code": {
|
||||||
|
"name": "room_code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"played_at": {
|
||||||
|
"name": "played_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"quiz_played_question_id_quiz_question_id_fk": {
|
||||||
|
"name": "quiz_played_question_id_quiz_question_id_fk",
|
||||||
|
"tableFrom": "quiz_played",
|
||||||
|
"tableTo": "quiz_question",
|
||||||
|
"columnsFrom": [
|
||||||
|
"question_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"quiz_played_question_id_room_code_pk": {
|
||||||
|
"name": "quiz_played_question_id_room_code_pk",
|
||||||
|
"columns": [
|
||||||
|
"question_id",
|
||||||
|
"room_code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.quiz_question": {
|
||||||
|
"name": "quiz_question",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"name": "format",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"name": "prompt",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"difficulty": {
|
||||||
|
"name": "difficulty",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"name": "lang",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'fr'"
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"name": "active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"choices": {
|
||||||
|
"name": "choices",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"correct_index": {
|
||||||
|
"name": "correct_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"accepted_answers": {
|
||||||
|
"name": "accepted_answers",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"quiz_question_prompt_unique": {
|
||||||
|
"name": "quiz_question_prompt_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "prompt",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": "\"quiz_question\".\"format\" <> 'image_reveal'",
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"quiz_question_image_unique": {
|
||||||
|
"name": "quiz_question_image_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "image_url",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": "\"quiz_question\".\"image_url\" is not null",
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"quiz_question_category_id_quiz_category_id_fk": {
|
||||||
|
"name": "quiz_question_category_id_quiz_category_id_fk",
|
||||||
|
"tableFrom": "quiz_question",
|
||||||
|
"tableTo": "quiz_category",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,13 @@
|
||||||
"when": 1781129455626,
|
"when": 1781129455626,
|
||||||
"tag": "0001_fancy_captain_stacy",
|
"tag": "0001_fancy_captain_stacy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781185531830,
|
||||||
|
"tag": "0002_lowly_tenebrous",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ interface CreateBody {
|
||||||
prompt?: string
|
prompt?: string
|
||||||
category?: string
|
category?: string
|
||||||
difficulty?: number
|
difficulty?: number
|
||||||
|
lang?: string
|
||||||
choices?: string[]
|
choices?: string[]
|
||||||
correctIndex?: number
|
correctIndex?: number
|
||||||
acceptedAnswers?: string[]
|
acceptedAnswers?: string[]
|
||||||
|
|
@ -46,7 +47,14 @@ function validate(
|
||||||
return { error: "Difficulté invalide (1 à 3)." }
|
return { error: "Difficulté invalide (1 à 3)." }
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = { categoryId, format, prompt, difficulty, source: "manual" }
|
const base = {
|
||||||
|
categoryId,
|
||||||
|
format,
|
||||||
|
prompt,
|
||||||
|
difficulty,
|
||||||
|
source: "manual",
|
||||||
|
lang: body.lang === "en" ? "en" : "fr",
|
||||||
|
}
|
||||||
|
|
||||||
if (format === "free" || format === "image_reveal") {
|
if (format === "free" || format === "image_reveal") {
|
||||||
const answers = (body.acceptedAnswers ?? [])
|
const answers = (body.acceptedAnswers ?? [])
|
||||||
|
|
@ -138,6 +146,8 @@ export async function adminRoutes(app: FastifyInstance) {
|
||||||
correctIndex: quizQuestion.correctIndex,
|
correctIndex: quizQuestion.correctIndex,
|
||||||
acceptedAnswers: quizQuestion.acceptedAnswers,
|
acceptedAnswers: quizQuestion.acceptedAnswers,
|
||||||
imageUrl: quizQuestion.imageUrl,
|
imageUrl: quizQuestion.imageUrl,
|
||||||
|
lang: quizQuestion.lang,
|
||||||
|
active: quizQuestion.active,
|
||||||
category: quizCategory.name,
|
category: quizCategory.name,
|
||||||
})
|
})
|
||||||
.from(quizQuestion)
|
.from(quizQuestion)
|
||||||
|
|
@ -174,6 +184,25 @@ export async function adminRoutes(app: FastifyInstance) {
|
||||||
return reply.code(201).send({ id: inserted[0].id })
|
return reply.code(201).send({ id: inserted[0].id })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { id: string }; Body: { active?: boolean } }>(
|
||||||
|
"/questions/:id",
|
||||||
|
async (req, reply) => {
|
||||||
|
const active = req.body?.active
|
||||||
|
if (typeof active !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "Champ 'active' requis." })
|
||||||
|
}
|
||||||
|
const updated = await db!
|
||||||
|
.update(quizQuestion)
|
||||||
|
.set({ active })
|
||||||
|
.where(eq(quizQuestion.id, req.params.id))
|
||||||
|
.returning({ id: quizQuestion.id })
|
||||||
|
if (updated.length === 0) {
|
||||||
|
return reply.code(404).send({ error: "Question introuvable." })
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/questions/:id", async (req, reply) => {
|
app.delete<{ Params: { id: string } }>("/questions/:id", async (req, reply) => {
|
||||||
const deleted = await db!
|
const deleted = await db!
|
||||||
.delete(quizQuestion)
|
.delete(quizQuestion)
|
||||||
|
|
|
||||||
55
apps/server/src/db/history-repo.ts
Normal file
55
apps/server/src/db/history-repo.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Historique de parties (table game_history). No-op si pas de DB.
|
||||||
|
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "./index"
|
||||||
|
import { gameHistory } from "./schema"
|
||||||
|
|
||||||
|
export interface GameResultEntry {
|
||||||
|
name: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGameHistory(
|
||||||
|
roomCode: string,
|
||||||
|
modes: string[],
|
||||||
|
results: GameResultEntry[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (!db) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await db.insert(gameHistory).values({ roomCode, modes, results })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameHistoryRow {
|
||||||
|
id: string
|
||||||
|
playedAt: string
|
||||||
|
modes: string[]
|
||||||
|
results: GameResultEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGameHistory(
|
||||||
|
roomCode: string,
|
||||||
|
limit = 10
|
||||||
|
): Promise<GameHistoryRow[]> {
|
||||||
|
if (!db) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: gameHistory.id,
|
||||||
|
playedAt: gameHistory.playedAt,
|
||||||
|
modes: gameHistory.modes,
|
||||||
|
results: gameHistory.results,
|
||||||
|
})
|
||||||
|
.from(gameHistory)
|
||||||
|
.where(eq(gameHistory.roomCode, roomCode))
|
||||||
|
.orderBy(desc(gameHistory.playedAt))
|
||||||
|
.limit(limit)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
playedAt:
|
||||||
|
r.playedAt instanceof Date ? r.playedAt.toISOString() : String(r.playedAt),
|
||||||
|
modes: (r.modes as string[]) ?? [],
|
||||||
|
results: (r.results as GameResultEntry[]) ?? [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@ export async function loadQuizPool(
|
||||||
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
eq(quizQuestion.active, true),
|
||||||
inArray(quizQuestion.format, formats),
|
inArray(quizQuestion.format, formats),
|
||||||
categories.length > 0
|
categories.length > 0
|
||||||
? inArray(quizCategory.name, categories)
|
? inArray(quizCategory.name, categories)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import { sql } from "drizzle-orm"
|
import { sql } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
|
|
@ -31,8 +32,12 @@ export const quizQuestion = pgTable(
|
||||||
prompt: text("prompt").notNull(),
|
prompt: text("prompt").notNull(),
|
||||||
/** 1 (facile) .. 3 (difficile). */
|
/** 1 (facile) .. 3 (difficile). */
|
||||||
difficulty: integer("difficulty").notNull().default(1),
|
difficulty: integer("difficulty").notNull().default(1),
|
||||||
/** 'opentdb' | 'manual' */
|
/** 'opentdb' | 'manual' | 'pokeapi' */
|
||||||
source: text("source").notNull(),
|
source: text("source").notNull(),
|
||||||
|
/** Langue de la question ('fr' | 'en'). */
|
||||||
|
lang: text("lang").notNull().default("fr"),
|
||||||
|
/** Désactivée (back-office) : exclue des parties sans être supprimée. */
|
||||||
|
active: boolean("active").notNull().default(true),
|
||||||
// Champs selon le format (NULL si non applicable) :
|
// Champs selon le format (NULL si non applicable) :
|
||||||
choices: jsonb("choices").$type<string[]>(),
|
choices: jsonb("choices").$type<string[]>(),
|
||||||
correctIndex: integer("correct_index"),
|
correctIndex: integer("correct_index"),
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ async function fetchPokemon(id: number): Promise<NewQuizQuestion | null> {
|
||||||
prompt: PROMPT,
|
prompt: PROMPT,
|
||||||
difficulty: 2,
|
difficulty: 2,
|
||||||
source: "pokeapi",
|
source: "pokeapi",
|
||||||
|
lang: "fr",
|
||||||
acceptedAnswers: answers,
|
acceptedAnswers: answers,
|
||||||
imageUrl: art,
|
imageUrl: art,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ function toQuestion(
|
||||||
prompt,
|
prompt,
|
||||||
difficulty,
|
difficulty,
|
||||||
source: "opentdb",
|
source: "opentdb",
|
||||||
|
lang: "en",
|
||||||
choices: ["True", "False"],
|
choices: ["True", "False"],
|
||||||
correctIndex: correct === "True" ? 0 : 1,
|
correctIndex: correct === "True" ? 0 : 1,
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +129,7 @@ function toQuestion(
|
||||||
prompt,
|
prompt,
|
||||||
difficulty,
|
difficulty,
|
||||||
source: "opentdb",
|
source: "opentdb",
|
||||||
|
lang: "en",
|
||||||
choices,
|
choices,
|
||||||
correctIndex: choices.indexOf(correct),
|
correctIndex: choices.indexOf(correct),
|
||||||
}
|
}
|
||||||
|
|
@ -177,6 +179,7 @@ async function seedManual(): Promise<number> {
|
||||||
prompt: q.prompt,
|
prompt: q.prompt,
|
||||||
difficulty: q.difficulty,
|
difficulty: q.difficulty,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
lang: "fr",
|
||||||
choices: q.choices,
|
choices: q.choices,
|
||||||
correctIndex: q.correctIndex,
|
correctIndex: q.correctIndex,
|
||||||
acceptedAnswers: q.acceptedAnswers,
|
acceptedAnswers: q.acceptedAnswers,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import type { IoServer } from "../socket"
|
import type { IoServer } from "../socket"
|
||||||
import type { RoomManager, ServerRoom } from "../rooms"
|
import type { RoomManager, ServerRoom } from "../rooms"
|
||||||
|
import { saveGameHistory } from "../db/history-repo"
|
||||||
import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
|
import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
|
||||||
import type { GameRound, RoomGameController, RoundContext } from "./round"
|
import type { GameRound, RoomGameController, RoundContext } from "./round"
|
||||||
|
|
||||||
|
|
@ -87,6 +88,18 @@ export class GameEngine implements RoomGameController {
|
||||||
tracks: tracks.length > 0 ? tracks : undefined,
|
tracks: tracks.length > 0 ? tracks : undefined,
|
||||||
recap: this.history.length > 0 ? this.history : undefined,
|
recap: this.history.length > 0 ? this.history : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Historique (fire-and-forget, DB optionnelle).
|
||||||
|
const modes = [...new Set(this.room.settings.rounds.map((r) => r.type))]
|
||||||
|
const results = this.scoreboard()
|
||||||
|
.map((s) => ({
|
||||||
|
name: this.room.players.get(s.playerId)?.name ?? "?",
|
||||||
|
score: s.score,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
void saveGameHistory(this.room.code, modes, results).catch((err) =>
|
||||||
|
console.error("[history] save failed", err)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
|
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { env, isDev } from "./env"
|
||||||
import { createSocketServer } from "./socket"
|
import { createSocketServer } from "./socket"
|
||||||
import { adminRoutes } from "./admin/routes"
|
import { adminRoutes } from "./admin/routes"
|
||||||
import { listCategories } from "./db/quiz-repo"
|
import { listCategories } from "./db/quiz-repo"
|
||||||
|
import { listGameHistory } from "./db/history-repo"
|
||||||
import "./game/modes" // enregistre les épreuves (registerRound)
|
import "./game/modes" // enregistre les épreuves (registerRound)
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
|
@ -29,6 +30,11 @@ app.get("/health", async () => ({ status: "ok", uptime: process.uptime() }))
|
||||||
// Catégories de quiz disponibles (public, pour le filtre du lobby).
|
// Catégories de quiz disponibles (public, pour le filtre du lobby).
|
||||||
app.get("/api/categories", async () => listCategories())
|
app.get("/api/categories", async () => listCategories())
|
||||||
|
|
||||||
|
// Historique des parties d'une room (public).
|
||||||
|
app.get<{ Params: { code: string } }>("/api/history/:code", async (req) =>
|
||||||
|
listGameHistory(req.params.code.toUpperCase())
|
||||||
|
)
|
||||||
|
|
||||||
await app.register(adminRoutes, { prefix: "/api/admin" })
|
await app.register(adminRoutes, { prefix: "/api/admin" })
|
||||||
|
|
||||||
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
|
// Socket.IO se greffe sur le serveur HTTP sous-jacent de Fastify.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { SiYoutube } from "@icons-pack/react-simple-icons"
|
import { SiYoutube } from "@icons-pack/react-simple-icons"
|
||||||
import { fetchCategories } from "@/lib/categories"
|
import { fetchCategories } from "@/lib/categories"
|
||||||
|
import { fetchHistory } from "@/lib/history"
|
||||||
import type {
|
import type {
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
GameType,
|
GameType,
|
||||||
|
|
@ -140,6 +141,11 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
snapshot.settings
|
snapshot.settings
|
||||||
const allCategories =
|
const allCategories =
|
||||||
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
|
useQuery({ queryKey: ["categories"], queryFn: fetchCategories }).data ?? []
|
||||||
|
const history =
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["history", snapshot.code],
|
||||||
|
queryFn: () => fetchHistory(snapshot.code),
|
||||||
|
}).data ?? []
|
||||||
|
|
||||||
const [quizCount, setQuizCount] = useState(5)
|
const [quizCount, setQuizCount] = useState(5)
|
||||||
const [imageCount, setImageCount] = useState(5)
|
const [imageCount, setImageCount] = useState(5)
|
||||||
|
|
@ -433,6 +439,38 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-destructive text-center text-sm">{error}</p>}
|
{error && <p className="text-destructive text-center text-sm">{error}</p>}
|
||||||
|
|
||||||
|
{history.length > 0 && (
|
||||||
|
<details className="rounded-xl border text-left">
|
||||||
|
<summary className="text-muted-foreground cursor-pointer p-3 text-sm font-medium select-none">
|
||||||
|
Parties précédentes ({history.length})
|
||||||
|
</summary>
|
||||||
|
<ul className="flex flex-col gap-2 p-3 pt-0">
|
||||||
|
{history.map((g) => {
|
||||||
|
const top = [...g.results].sort((a, b) => b.score - a.score)
|
||||||
|
return (
|
||||||
|
<li key={g.id} className="bg-muted/40 rounded-lg p-2 text-xs">
|
||||||
|
<p className="text-muted-foreground text-[10px] uppercase">
|
||||||
|
{new Date(g.playedAt).toLocaleString("fr-FR", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
{" · "}
|
||||||
|
{g.modes.join(", ")}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
🏆 {top[0]?.name ?? "?"}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
— {top.map((r) => `${r.name} ${r.score}`).join(" · ")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export interface AdminQuestion {
|
||||||
correctIndex: number | null
|
correctIndex: number | null
|
||||||
acceptedAnswers: string[] | null
|
acceptedAnswers: string[] | null
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
|
lang: string
|
||||||
|
active: boolean
|
||||||
category: string | null
|
category: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +35,7 @@ export interface NewQuestionInput {
|
||||||
prompt: string
|
prompt: string
|
||||||
category: string
|
category: string
|
||||||
difficulty: number
|
difficulty: number
|
||||||
|
lang?: string
|
||||||
choices?: string[]
|
choices?: string[]
|
||||||
correctIndex?: number
|
correctIndex?: number
|
||||||
acceptedAnswers?: string[]
|
acceptedAnswers?: string[]
|
||||||
|
|
@ -83,6 +86,11 @@ export const adminApi = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
}),
|
}),
|
||||||
|
setActive: (id: string, active: boolean) =>
|
||||||
|
request<{ ok: true }>(`/questions/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ active }),
|
||||||
|
}),
|
||||||
deleteQuestion: (id: string) =>
|
deleteQuestion: (id: string) =>
|
||||||
request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }),
|
request<{ ok: true }>(`/questions/${id}`, { method: "DELETE" }),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
apps/web/src/lib/history.ts
Normal file
22
apps/web/src/lib/history.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// Historique public des parties d'une room.
|
||||||
|
|
||||||
|
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
|
||||||
|
|
||||||
|
export interface GameHistoryEntry {
|
||||||
|
id: string
|
||||||
|
playedAt: string
|
||||||
|
modes: string[]
|
||||||
|
results: { name: string; score: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHistory(code: string): Promise<GameHistoryEntry[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SERVER_URL}/api/history/${code}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (await res.json()) as GameHistoryEntry[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Link } from "wouter"
|
import { Link } from "wouter"
|
||||||
import { Trash2, Upload } from "lucide-react"
|
import { Eye, EyeOff, Trash2, Upload } from "lucide-react"
|
||||||
import type { QuizFormat } from "@nerdware/shared"
|
import type { QuizFormat } from "@nerdware/shared"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import {
|
import {
|
||||||
|
|
@ -86,6 +86,12 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggle = useMutation({
|
||||||
|
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
|
||||||
|
adminApi.setActive(id, active),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-questions"] }),
|
||||||
|
})
|
||||||
|
|
||||||
const unauthorized =
|
const unauthorized =
|
||||||
questions.isError && /401|autoris/i.test(String(questions.error))
|
questions.isError && /401|autoris/i.test(String(questions.error))
|
||||||
|
|
||||||
|
|
@ -126,6 +132,7 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
|
||||||
key={q.id}
|
key={q.id}
|
||||||
q={q}
|
q={q}
|
||||||
onDelete={() => remove.mutate(q.id)}
|
onDelete={() => remove.mutate(q.id)}
|
||||||
|
onToggle={() => toggle.mutate({ id: q.id, active: !q.active })}
|
||||||
deleting={remove.isPending}
|
deleting={remove.isPending}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -140,14 +147,20 @@ function AdminPanel({ onLogout }: { onLogout: () => void }) {
|
||||||
function QuestionRow({
|
function QuestionRow({
|
||||||
q,
|
q,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onToggle,
|
||||||
deleting,
|
deleting,
|
||||||
}: {
|
}: {
|
||||||
q: AdminQuestion
|
q: AdminQuestion
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onToggle: () => void
|
||||||
deleting: boolean
|
deleting: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<li className="bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3">
|
<li
|
||||||
|
className={`bg-muted/40 flex items-start justify-between gap-3 rounded-lg p-3 ${
|
||||||
|
q.active ? "" : "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{q.format === "image_reveal" && q.imageUrl && (
|
{q.format === "image_reveal" && q.imageUrl && (
|
||||||
<img
|
<img
|
||||||
src={assetUrl(q.imageUrl)}
|
src={assetUrl(q.imageUrl)}
|
||||||
|
|
@ -158,7 +171,9 @@ function QuestionRow({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium">{q.prompt}</p>
|
<p className="text-sm font-medium">{q.prompt}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} · {q.source}
|
{q.format} · {q.category ?? "—"} · diff. {q.difficulty} ·{" "}
|
||||||
|
{q.lang.toUpperCase()} · {q.source}
|
||||||
|
{!q.active && " · désactivée"}
|
||||||
</p>
|
</p>
|
||||||
{q.format === "free" || q.format === "image_reveal" ? (
|
{q.format === "free" || q.format === "image_reveal" ? (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
|
|
@ -172,6 +187,14 @@ function QuestionRow({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onToggle}
|
||||||
|
title={q.active ? "Désactiver" : "Activer"}
|
||||||
|
>
|
||||||
|
{q.active ? <Eye /> : <EyeOff />}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -191,6 +214,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
const [prompt, setPrompt] = useState("")
|
const [prompt, setPrompt] = useState("")
|
||||||
const [category, setCategory] = useState("")
|
const [category, setCategory] = useState("")
|
||||||
const [difficulty, setDifficulty] = useState(1)
|
const [difficulty, setDifficulty] = useState(1)
|
||||||
|
const [lang, setLang] = useState("fr")
|
||||||
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
|
const [choices, setChoices] = useState<string[]>(EMPTY_CHOICES)
|
||||||
const [correctIndex, setCorrectIndex] = useState(0)
|
const [correctIndex, setCorrectIndex] = useState(0)
|
||||||
const [answers, setAnswers] = useState("")
|
const [answers, setAnswers] = useState("")
|
||||||
|
|
@ -228,7 +252,7 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
const input: NewQuestionInput = { format, prompt, category, difficulty }
|
const input: NewQuestionInput = { format, prompt, category, difficulty, lang }
|
||||||
if (format === "free") {
|
if (format === "free") {
|
||||||
input.acceptedAnswers = answers
|
input.acceptedAnswers = answers
|
||||||
.split("\n")
|
.split("\n")
|
||||||
|
|
@ -277,6 +301,14 @@ function QuestionForm({ onCreated }: { onCreated: () => void }) {
|
||||||
<option value={2}>Moyen</option>
|
<option value={2}>Moyen</option>
|
||||||
<option value={3}>Difficile</option>
|
<option value={3}>Difficile</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => setLang(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="fr">FR</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue