New "Rébus" mode: guess the geek work from an emoji puzzle (free-text answer,
tolerant matching). Implemented as a quiz format `rebus` — reuses the whole quiz
machinery (pool, anti-repeat, speed scoring, recap, bots, back-office):
- shared: QuizFormat/GameType/MixMode + RoundConfig.pool gain "rebus"
- server: rebus is a text format; pool composes a rebus sub-pool (not filtered by
category); ~9 seeded emoji rebus in the in-code bank; admin accepts the format
- client: lobby tile + sub-mode + count; big centered emoji display in the round;
dedicated transition theme (🧩 RÉBUS); FR/EN strings; back-office form option
Verified e2e: a rebus game serves only rebus rounds with emoji prompts and the
recap shows the right answers. typecheck/lint/build/24 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
7.1 KiB
TypeScript
216 lines
7.1 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", "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 }
|
|
})
|
|
}
|