nerdware/apps/server/src/db/quiz-repo.ts
AyoubBenziza b3efa27287 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>
2026-06-11 17:12:07 +02:00

100 lines
2.8 KiB
TypeScript

// Accès aux questions de quiz en base. No-op si pas de DB (db === null).
import { and, eq, inArray, notInArray, sql } from "drizzle-orm"
import { db } from "./index"
import { quizCategory, quizPlayed, quizQuestion } from "./schema"
import type { QuizQuestion } from "../game/modes/quiz/questions"
/**
* Pool de questions mcq/truefalse non encore jouées par cette room,
* mélangées et limitées. Tableau vide si pas de DB ou rien à servir.
*/
/** Catégories existantes (pour le filtre du lobby). */
export async function listCategories(): Promise<string[]> {
if (!db) {
return []
}
const rows = await db
.select({ name: quizCategory.name })
.from(quizCategory)
.orderBy(quizCategory.name)
return rows.map((r) => r.name)
}
export async function loadQuizPool(
roomCode: string,
limit: number,
formats: string[],
categories: string[] = []
): Promise<QuizQuestion[]> {
if (!db || formats.length === 0) {
return []
}
const alreadyPlayed = db
.select({ id: quizPlayed.questionId })
.from(quizPlayed)
.where(eq(quizPlayed.roomCode, roomCode))
const rows = await db
.select({
id: quizQuestion.id,
format: quizQuestion.format,
prompt: quizQuestion.prompt,
choices: quizQuestion.choices,
correctIndex: quizQuestion.correctIndex,
acceptedAnswers: quizQuestion.acceptedAnswers,
imageUrl: quizQuestion.imageUrl,
difficulty: quizQuestion.difficulty,
category: quizCategory.name,
})
.from(quizQuestion)
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where(
and(
eq(quizQuestion.active, true),
inArray(quizQuestion.format, formats),
categories.length > 0
? inArray(quizCategory.name, categories)
: undefined,
notInArray(quizQuestion.id, alreadyPlayed)
)
)
.orderBy(sql`random()`)
.limit(limit)
return rows
.filter((r) => {
if (r.format === "free") {
return (r.acceptedAnswers?.length ?? 0) > 0
}
if (r.format === "image_reveal") {
return !!r.imageUrl && (r.acceptedAnswers?.length ?? 0) > 0
}
return r.choices && r.correctIndex !== null
})
.map((r) => ({
id: r.id,
format: r.format as QuizQuestion["format"],
prompt: r.prompt,
choices: r.choices ?? undefined,
correctIndex: r.correctIndex ?? undefined,
acceptedAnswers: r.acceptedAnswers ?? undefined,
imageUrl: r.imageUrl ?? undefined,
category: r.category ?? "Quiz",
difficulty: r.difficulty,
}))
}
/** Marque une question comme jouée par cette room (anti-répétition). */
export async function markQuestionPlayed(
roomCode: string,
questionId: string
): Promise<void> {
if (!db) {
return
}
await db
.insert(quizPlayed)
.values({ roomCode, questionId })
.onConflictDoNothing()
}