// 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", "rebus"] 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 lang?: string 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", lang: body.lang === "en" ? "en" : "fr", } if (format === "free" || format === "image_reveal" || format === "rebus") { 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, lang: quizQuestion.lang, active: quizQuestion.active, 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.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) => { 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 } }) }