nerdware/apps/server/src/db/seed.ts
AyoubBenziza cfc5339a25 fix(server): connect via PG* vars (robust to special chars in password)
postgres-js failed to parse DATABASE_URL when the password contained URL-special
characters (@ : / # ...). New createSqlClient(): uses DATABASE_URL if set, else
falls back to PG* env vars (PGHOST/PGUSER/PGPASSWORD/...) which postgres-js reads
directly — password passed verbatim, no URL encoding. docker-compose.prod.yml now
sets PG* for the server instead of an inline DSN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:54:56 +02:00

195 lines
5.5 KiB
TypeScript

// Seed du contenu quiz. One-shot au déploiement : bun run db:seed
// - Open Trivia DB : catégories geek (mcq + truefalse), respect 1 req / 5 s.
// - Banque FR en dur : insérée comme source 'manual'.
// Idempotent : prompt unique → onConflictDoNothing.
import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/postgres-js"
import { createSqlClient } from "./client"
import {
quizCategory,
quizQuestion,
type NewQuizQuestion,
} from "./schema"
import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions"
const client = createSqlClient({ max: 1 })
if (!client) {
console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.")
process.exit(1)
}
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché).
const OPENTDB_CATEGORIES = [
{ id: 15, name: "Jeux vidéo" },
{ id: 31, name: "Anime & Manga" },
{ id: 29, name: "Comics" },
{ id: 11, name: "Cinéma" },
{ id: 14, name: "Télévision" },
{ id: 18, name: "Informatique" },
]
const AMOUNT = 30
const RATE_LIMIT_MS = 5200 // OpenTDB : 1 requête / 5 s par IP
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const decode = (s: string) => Buffer.from(s, "base64").toString("utf8")
const difficultyOf = (d: string) => (d === "hard" ? 3 : d === "medium" ? 2 : 1)
// Tous les champs string sont encodés en base64 (encode=base64).
interface OtdbQuestion {
type: string
difficulty: string
question: string
correct_answer: string
incorrect_answers: string[]
}
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
async function getToken(): Promise<string | undefined> {
try {
const res = await fetch("https://opentdb.com/api_token.php?command=request")
const json = (await res.json()) as { token?: string }
return json.token
} catch {
return undefined
}
}
async function fetchBatch(
categoryId: number,
type: "multiple" | "boolean",
token: string | undefined
): Promise<OtdbQuestion[]> {
const url =
`https://opentdb.com/api.php?amount=${AMOUNT}&category=${categoryId}` +
`&type=${type}&encode=base64${token ? `&token=${token}` : ""}`
const res = await fetch(url)
const json = (await res.json()) as {
response_code: number
results: OtdbQuestion[]
}
if (json.response_code === 5) {
// Rate limit : on attend et on réessaie une fois.
await sleep(RATE_LIMIT_MS)
return fetchBatch(categoryId, type, token)
}
return json.response_code === 0 ? json.results : []
}
/** Upsert une catégorie et renvoie son id. */
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
}
function toQuestion(
q: OtdbQuestion,
categoryId: string
): NewQuizQuestion {
// Avec encode=base64, TOUS les champs sont encodés (type/difficulty inclus).
const prompt = decode(q.question)
const type = decode(q.type)
const difficulty = difficultyOf(decode(q.difficulty))
if (type === "boolean") {
const correct = decode(q.correct_answer) // "True" | "False"
return {
categoryId,
format: "truefalse",
prompt,
difficulty,
source: "opentdb",
lang: "en",
choices: ["True", "False"],
correctIndex: correct === "True" ? 0 : 1,
}
}
const correct = decode(q.correct_answer)
const choices = shuffle([
correct,
...q.incorrect_answers.map(decode),
])
return {
categoryId,
format: "mcq",
prompt,
difficulty,
source: "opentdb",
lang: "en",
choices,
correctIndex: choices.indexOf(correct),
}
}
async function insertQuestions(rows: NewQuizQuestion[]): Promise<number> {
if (rows.length === 0) {
return 0
}
const inserted = await db
.insert(quizQuestion)
.values(rows)
.onConflictDoNothing()
.returning({ id: quizQuestion.id })
return inserted.length
}
async function seedOpenTdb(): Promise<number> {
const token = await getToken()
let total = 0
let first = true
for (const cat of OPENTDB_CATEGORIES) {
const categoryId = await upsertCategory(cat.name)
for (const type of ["multiple", "boolean"] as const) {
if (!first) {
await sleep(RATE_LIMIT_MS)
}
first = false
const batch = await fetchBatch(cat.id, type, token)
const rows = batch.map((q) => toQuestion(q, categoryId))
const n = await insertQuestions(rows)
total += n
console.log(` ${cat.name} (${type}): +${n}`)
}
}
return total
}
async function seedManual(): Promise<number> {
let total = 0
for (const q of QUIZ_QUESTIONS) {
const categoryId = await upsertCategory(q.category)
total += await insertQuestions([
{
categoryId,
format: q.format,
prompt: q.prompt,
difficulty: q.difficulty,
source: "manual",
lang: "fr",
choices: q.choices,
correctIndex: q.correctIndex,
acceptedAnswers: q.acceptedAnswers,
},
])
}
return total
}
console.log("Seed Open Trivia DB (respect du 1 req / 5 s, patientez)…")
const otdb = await seedOpenTdb()
console.log("Seed banque FR (manual)…")
const manual = await seedManual()
console.log(`Terminé : ${otdb} questions OpenTDB + ${manual} manuelles.`)
await client.end()