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>
95 lines
3 KiB
TypeScript
95 lines
3 KiB
TypeScript
// Seed du mode Images depuis PokéAPI : "devine le Pokémon" à partir de
|
|
// l'artwork officiel (URL distante, pas d'upload). Réponses acceptées FR + EN.
|
|
// bun run db:seed:images (gen 1, 151 Pokémon)
|
|
// IMAGE_SEED_COUNT=50 bun run db:seed:images
|
|
// Idempotent : déduplication par image_url (onConflictDoNothing).
|
|
|
|
import { eq } from "drizzle-orm"
|
|
import { drizzle } from "drizzle-orm/postgres-js"
|
|
import postgres from "postgres"
|
|
import { env } from "../env"
|
|
import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema"
|
|
|
|
if (!env.databaseUrl) {
|
|
console.error("DATABASE_URL manquant — impossible de seeder.")
|
|
process.exit(1)
|
|
}
|
|
|
|
const COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151)
|
|
const CATEGORY = "Pokémon"
|
|
const PROMPT = "Quel est ce Pokémon ?"
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
|
|
const client = postgres(env.databaseUrl, { max: 1 })
|
|
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
|
|
|
|
async function upsertCategory(name: string): Promise<string> {
|
|
await db.insert(quizCategory).values({ name }).onConflictDoNothing()
|
|
const [row] = await db
|
|
.select({ id: quizCategory.id })
|
|
.from(quizCategory)
|
|
.where(eq(quizCategory.name, name))
|
|
return row.id
|
|
}
|
|
|
|
interface Pokemon {
|
|
name: string
|
|
sprites: { other?: { ["official-artwork"]?: { front_default: string | null } } }
|
|
}
|
|
interface Species {
|
|
names: { name: string; language: { name: string } }[]
|
|
}
|
|
|
|
async function fetchPokemon(id: number): Promise<NewQuizQuestion | null> {
|
|
try {
|
|
const [pRes, sRes] = await Promise.all([
|
|
fetch(`https://pokeapi.co/api/v2/pokemon/${id}`),
|
|
fetch(`https://pokeapi.co/api/v2/pokemon-species/${id}`),
|
|
])
|
|
if (!pRes.ok || !sRes.ok) {
|
|
return null
|
|
}
|
|
const p = (await pRes.json()) as Pokemon
|
|
const s = (await sRes.json()) as Species
|
|
const art = p.sprites.other?.["official-artwork"]?.front_default
|
|
if (!art) {
|
|
return null
|
|
}
|
|
const fr = s.names.find((n) => n.language.name === "fr")?.name
|
|
const en = s.names.find((n) => n.language.name === "en")?.name ?? p.name
|
|
const answers = [...new Set([fr, en].filter(Boolean) as string[])]
|
|
return {
|
|
categoryId: "", // rempli plus bas
|
|
format: "image_reveal",
|
|
prompt: PROMPT,
|
|
difficulty: 2,
|
|
source: "pokeapi",
|
|
lang: "fr",
|
|
acceptedAnswers: answers,
|
|
imageUrl: art,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
console.log(`Seed Images depuis PokéAPI (${COUNT} Pokémon)…`)
|
|
const categoryId = await upsertCategory(CATEGORY)
|
|
let inserted = 0
|
|
for (let id = 1; id <= COUNT; id++) {
|
|
const q = await fetchPokemon(id)
|
|
if (q) {
|
|
const res = await db
|
|
.insert(quizQuestion)
|
|
.values({ ...q, categoryId })
|
|
.onConflictDoNothing()
|
|
.returning({ id: quizQuestion.id })
|
|
inserted += res.length
|
|
}
|
|
if (id % 25 === 0) {
|
|
console.log(` ${id}/${COUNT}…`)
|
|
}
|
|
await sleep(40) // courtoisie envers l'API
|
|
}
|
|
console.log(`Terminé : ${inserted} Pokémon ajoutés (mode Images).`)
|
|
await client.end()
|