- image_reveal display: larger, object-contain (whole image up to 58vh), no crop - image prompt optional in back-office (defaults to "Qui est-ce ?"), overridable - mixed mode reworked: select "Mixte" then toggle sub-modes (Quiz/Images/ Blindtest). Blindtest sub-mode locked under 3 players; mixed itself no longer needs 3. Server quiz pool formats derived from gameType + mixedModes. shared: MixMode + RoomSettings.mixedModes (default quiz+image). - DB: prompt uniqueness is now a partial index (excludes image_reveal) + unique index on image_url; lets many images share the generic prompt, dedup by URL. - seed: db:seed:images pulls Pokémon (PokéAPI) as image_reveal questions with official artwork (remote URL) + FR/EN accepted answers; idempotent by image_url. Verified: migration applied, Pokémon seed inserts FR+EN, image mode serves only image_reveal, all checks green (23 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
6.3 KiB
TypeScript
187 lines
6.3 KiB
TypeScript
// Back-office quiz (HTTP, hors temps réel). Protégé par ADMIN_TOKEN.
|
|
// CRUD des questions manuelles écrites en base (source 'manual').
|
|
|
|
import { randomUUID } from "node:crypto"
|
|
import { mkdir, writeFile } from "node:fs/promises"
|
|
import { extname, resolve } from "node:path"
|
|
import type { FastifyInstance } from "fastify"
|
|
import { desc, eq } from "drizzle-orm"
|
|
import { env } from "../env"
|
|
import { db } from "../db"
|
|
import { quizCategory, quizQuestion, type NewQuizQuestion } from "../db/schema"
|
|
|
|
const FORMATS = ["mcq", "truefalse", "free", "image_reveal"] as const
|
|
type AdminFormat = (typeof FORMATS)[number]
|
|
const IMAGE_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]
|
|
|
|
interface CreateBody {
|
|
format?: string
|
|
prompt?: string
|
|
category?: string
|
|
difficulty?: number
|
|
choices?: string[]
|
|
correctIndex?: number
|
|
acceptedAnswers?: string[]
|
|
imageUrl?: string
|
|
}
|
|
|
|
/** Valide le corps et renvoie soit une erreur, soit la ligne à insérer. */
|
|
function validate(
|
|
body: CreateBody,
|
|
categoryId: string
|
|
): { error: string } | { value: NewQuizQuestion } {
|
|
const format = body.format as AdminFormat
|
|
if (!FORMATS.includes(format)) {
|
|
return { error: "Format invalide." }
|
|
}
|
|
// L'intitulé est optionnel pour image_reveal (défaut générique).
|
|
const prompt =
|
|
(body.prompt ?? "").trim() ||
|
|
(format === "image_reveal" ? "Qui est-ce ?" : "")
|
|
if (prompt.length === 0) {
|
|
return { error: "L'intitulé est obligatoire." }
|
|
}
|
|
const difficulty = Number(body.difficulty)
|
|
if (![1, 2, 3].includes(difficulty)) {
|
|
return { error: "Difficulté invalide (1 à 3)." }
|
|
}
|
|
|
|
const base = { categoryId, format, prompt, difficulty, source: "manual" }
|
|
|
|
if (format === "free" || format === "image_reveal") {
|
|
const answers = (body.acceptedAnswers ?? [])
|
|
.map((a) => a.trim())
|
|
.filter(Boolean)
|
|
if (answers.length === 0) {
|
|
return { error: "Au moins une réponse acceptée est requise." }
|
|
}
|
|
if (format === "image_reveal" && !body.imageUrl) {
|
|
return { error: "Une image est requise." }
|
|
}
|
|
return {
|
|
value: {
|
|
...base,
|
|
acceptedAnswers: answers,
|
|
imageUrl: format === "image_reveal" ? body.imageUrl : undefined,
|
|
},
|
|
}
|
|
}
|
|
|
|
// mcq / truefalse
|
|
const choices = (body.choices ?? []).map((c) => c.trim()).filter(Boolean)
|
|
const min = format === "truefalse" ? 2 : 2
|
|
if (choices.length < min) {
|
|
return { error: "Au moins deux choix sont requis." }
|
|
}
|
|
const correctIndex = Number(body.correctIndex)
|
|
if (
|
|
!Number.isInteger(correctIndex) ||
|
|
correctIndex < 0 ||
|
|
correctIndex >= choices.length
|
|
) {
|
|
return { error: "La bonne réponse est invalide." }
|
|
}
|
|
return { value: { ...base, choices, correctIndex } }
|
|
}
|
|
|
|
export async function adminRoutes(app: FastifyInstance) {
|
|
// Auth (scopée à ce plugin) : ADMIN_TOKEN configuré + Bearer valide + DB up.
|
|
app.addHook("preHandler", async (req, reply) => {
|
|
if (!env.adminToken) {
|
|
return reply.code(503).send({ error: "Back-office désactivé (ADMIN_TOKEN manquant)." })
|
|
}
|
|
const header = req.headers.authorization ?? ""
|
|
const token = header.startsWith("Bearer ") ? header.slice(7) : ""
|
|
if (token !== env.adminToken) {
|
|
return reply.code(401).send({ error: "Non autorisé." })
|
|
}
|
|
if (!db) {
|
|
return reply.code(503).send({ error: "Base de données indisponible." })
|
|
}
|
|
})
|
|
|
|
app.post("/upload", async (req, reply) => {
|
|
const file = await req.file()
|
|
if (!file) {
|
|
return reply.code(400).send({ error: "Aucun fichier reçu." })
|
|
}
|
|
if (!file.mimetype.startsWith("image/")) {
|
|
return reply.code(400).send({ error: "Le fichier doit être une image." })
|
|
}
|
|
const ext = extname(file.filename ?? "").toLowerCase()
|
|
const safeExt = IMAGE_EXT.includes(ext) ? ext : ".jpg"
|
|
const name = `${randomUUID()}${safeExt}`
|
|
const buffer = await file.toBuffer()
|
|
// Crée le dossier si besoin (résilient s'il a été supprimé après le démarrage).
|
|
await mkdir(resolve(env.uploadsDir), { recursive: true })
|
|
await writeFile(resolve(env.uploadsDir, name), buffer)
|
|
return { url: `/uploads/${name}` }
|
|
})
|
|
|
|
app.get("/categories", async () => {
|
|
const rows = await db!
|
|
.select({ id: quizCategory.id, name: quizCategory.name })
|
|
.from(quizCategory)
|
|
.orderBy(quizCategory.name)
|
|
return rows
|
|
})
|
|
|
|
app.get("/questions", async () => {
|
|
const rows = await db!
|
|
.select({
|
|
id: quizQuestion.id,
|
|
format: quizQuestion.format,
|
|
prompt: quizQuestion.prompt,
|
|
difficulty: quizQuestion.difficulty,
|
|
source: quizQuestion.source,
|
|
choices: quizQuestion.choices,
|
|
correctIndex: quizQuestion.correctIndex,
|
|
acceptedAnswers: quizQuestion.acceptedAnswers,
|
|
imageUrl: quizQuestion.imageUrl,
|
|
category: quizCategory.name,
|
|
})
|
|
.from(quizQuestion)
|
|
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
|
.orderBy(desc(quizQuestion.createdAt))
|
|
.limit(500)
|
|
return rows
|
|
})
|
|
|
|
app.post("/questions", async (req, reply) => {
|
|
const body = (req.body ?? {}) as CreateBody
|
|
const categoryName = (body.category ?? "").trim()
|
|
if (categoryName.length === 0) {
|
|
return reply.code(400).send({ error: "La catégorie est obligatoire." })
|
|
}
|
|
await db!.insert(quizCategory).values({ name: categoryName }).onConflictDoNothing()
|
|
const [cat] = await db!
|
|
.select({ id: quizCategory.id })
|
|
.from(quizCategory)
|
|
.where(eq(quizCategory.name, categoryName))
|
|
|
|
const result = validate(body, cat.id)
|
|
if ("error" in result) {
|
|
return reply.code(400).send({ error: result.error })
|
|
}
|
|
const inserted = await db!
|
|
.insert(quizQuestion)
|
|
.values(result.value)
|
|
.onConflictDoNothing()
|
|
.returning({ id: quizQuestion.id })
|
|
if (inserted.length === 0) {
|
|
return reply.code(409).send({ error: "Une question avec cet intitulé existe déjà." })
|
|
}
|
|
return reply.code(201).send({ id: inserted[0].id })
|
|
})
|
|
|
|
app.delete<{ Params: { id: string } }>("/questions/:id", async (req, reply) => {
|
|
const deleted = await db!
|
|
.delete(quizQuestion)
|
|
.where(eq(quizQuestion.id, req.params.id))
|
|
.returning({ id: quizQuestion.id })
|
|
if (deleted.length === 0) {
|
|
return reply.code(404).send({ error: "Question introuvable." })
|
|
}
|
|
return { ok: true }
|
|
})
|
|
}
|