nerdware/apps/server/src/admin/routes.ts
AyoubBenziza 8bc184fab7 feat: bigger image_reveal, optional image prompt, mixed sub-mode toggles, Pokémon seed
- 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>
2026-06-11 00:20:28 +02:00

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 }
})
}