feat(server): Drizzle + PostgreSQL schema + Open Trivia DB seed #5

Merged
ayoub merged 1 commit from feature/quiz-db into dev 2026-06-10 10:34:05 +00:00
18 changed files with 950 additions and 26 deletions
Showing only changes of commit 9fe30feec5 - Show all commits

View file

@ -2,3 +2,5 @@ NODE_ENV=development
HOST=0.0.0.0
PORT=3001
CORS_ORIGINS=http://localhost:5173
# Laisser vide pour jouer sans DB (banque de questions en dur).
DATABASE_URL=postgres://nerdware:nerdware@localhost:5432/nerdware

View file

@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
})

View file

@ -0,0 +1,39 @@
CREATE TABLE "game_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"room_code" text NOT NULL,
"played_at" timestamp with time zone DEFAULT now() NOT NULL,
"modes" jsonb,
"results" jsonb
);
--> statement-breakpoint
CREATE TABLE "quiz_category" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "quiz_category_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "quiz_played" (
"question_id" uuid NOT NULL,
"room_code" text NOT NULL,
"played_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "quiz_played_question_id_room_code_pk" PRIMARY KEY("question_id","room_code")
);
--> statement-breakpoint
CREATE TABLE "quiz_question" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"category_id" uuid,
"format" text NOT NULL,
"prompt" text NOT NULL,
"difficulty" integer DEFAULT 1 NOT NULL,
"source" text NOT NULL,
"choices" jsonb,
"correct_index" integer,
"accepted_answers" jsonb,
"image_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "quiz_question_prompt_unique" UNIQUE("prompt")
);
--> statement-breakpoint
ALTER TABLE "quiz_played" ADD CONSTRAINT "quiz_played_question_id_quiz_question_id_fk" FOREIGN KEY ("question_id") REFERENCES "public"."quiz_question"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "quiz_question" ADD CONSTRAINT "quiz_question_category_id_quiz_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."quiz_category"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,263 @@
{
"id": "c68e2f59-3d1b-4fca-8443-17fe243e2919",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.game_history": {
"name": "game_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"modes": {
"name": "modes",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"results": {
"name": "results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_category": {
"name": "quiz_category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_category_name_unique": {
"name": "quiz_category_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_played": {
"name": "quiz_played",
"schema": "",
"columns": {
"question_id": {
"name": "question_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"played_at": {
"name": "played_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_played_question_id_quiz_question_id_fk": {
"name": "quiz_played_question_id_quiz_question_id_fk",
"tableFrom": "quiz_played",
"tableTo": "quiz_question",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"quiz_played_question_id_room_code_pk": {
"name": "quiz_played_question_id_room_code_pk",
"columns": [
"question_id",
"room_code"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quiz_question": {
"name": "quiz_question",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"difficulty": {
"name": "difficulty",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"choices": {
"name": "choices",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"correct_index": {
"name": "correct_index",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"accepted_answers": {
"name": "accepted_answers",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"quiz_question_category_id_quiz_category_id_fk": {
"name": "quiz_question_category_id_quiz_category_id_fk",
"tableFrom": "quiz_question",
"tableTo": "quiz_category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"quiz_question_prompt_unique": {
"name": "quiz_question_prompt_unique",
"nullsNotDistinct": false,
"columns": [
"prompt"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1781085893179,
"tag": "0000_loose_doctor_spectrum",
"breakpoints": true
}
]
}

View file

@ -10,18 +10,25 @@
"build": "bun build ./src/index.ts --target bun --outdir dist",
"lint": "eslint",
"format": "prettier --write \"**/*.ts\"",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "bun src/db/migrate.ts",
"db:seed": "bun src/db/seed.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cors": "^11",
"@nerdware/shared": "workspace:*",
"drizzle-orm": "^0.45",
"fastify": "^5",
"postgres": "^3.4",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^10",
"@types/bun": "latest",
"@types/node": "^24",
"drizzle-kit": "^0.31",
"eslint": "^10",
"globals": "^17",
"pino-pretty": "^13",

View file

@ -0,0 +1,21 @@
// Client Drizzle/PostgreSQL. Optionnel : sans DATABASE_URL, `db` vaut null et
// le jeu retombe sur la banque de questions en dur (dev sans infra).
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { env } from "../env"
import * as schema from "./schema"
export type Database = ReturnType<typeof drizzle<typeof schema>>
function createDb(): Database | null {
if (!env.databaseUrl) {
return null
}
const client = postgres(env.databaseUrl)
return drizzle(client, { schema })
}
export const db = createDb()
export const hasDb = db !== null
export { schema }

View file

@ -0,0 +1,18 @@
// Applique les migrations générées (drizzle/*.sql) à la base.
// bun run db:migrate
import { drizzle } from "drizzle-orm/postgres-js"
import { migrate } from "drizzle-orm/postgres-js/migrator"
import postgres from "postgres"
import { env } from "../env"
if (!env.databaseUrl) {
console.error("DATABASE_URL manquant — rien à migrer.")
process.exit(1)
}
const client = postgres(env.databaseUrl, { max: 1 })
const db = drizzle(client)
await migrate(db, { migrationsFolder: "./drizzle" })
console.log("Migrations appliquées.")
await client.end()

View file

@ -0,0 +1,71 @@
// Accès aux questions de quiz en base. No-op si pas de DB (db === null).
import { and, eq, inArray, isNotNull, notInArray, sql } from "drizzle-orm"
import { db } from "./index"
import { quizCategory, quizPlayed, quizQuestion } from "./schema"
import type { QuizQuestion } from "../game/modes/quiz/questions"
/**
* Pool de questions mcq/truefalse non encore jouées par cette room,
* mélangées et limitées. Tableau vide si pas de DB ou rien à servir.
*/
export async function loadQuizPool(
roomCode: string,
limit: number
): Promise<QuizQuestion[]> {
if (!db) {
return []
}
const alreadyPlayed = db
.select({ id: quizPlayed.questionId })
.from(quizPlayed)
.where(eq(quizPlayed.roomCode, roomCode))
const rows = await db
.select({
id: quizQuestion.id,
format: quizQuestion.format,
prompt: quizQuestion.prompt,
choices: quizQuestion.choices,
correctIndex: quizQuestion.correctIndex,
difficulty: quizQuestion.difficulty,
category: quizCategory.name,
})
.from(quizQuestion)
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
.where(
and(
inArray(quizQuestion.format, ["mcq", "truefalse"]),
isNotNull(quizQuestion.correctIndex),
notInArray(quizQuestion.id, alreadyPlayed)
)
)
.orderBy(sql`random()`)
.limit(limit)
return rows
.filter((r) => r.choices && r.correctIndex !== null)
.map((r) => ({
id: r.id,
format: r.format as QuizQuestion["format"],
prompt: r.prompt,
choices: r.choices as string[],
correctIndex: r.correctIndex as number,
category: r.category ?? "Quiz",
difficulty: r.difficulty,
}))
}
/** Marque une question comme jouée par cette room (anti-répétition). */
export async function markQuestionPlayed(
roomCode: string,
questionId: string
): Promise<void> {
if (!db) {
return
}
await db
.insert(quizPlayed)
.values({ roomCode, questionId })
.onConflictDoNothing()
}

View file

@ -0,0 +1,72 @@
// Schéma persistant (PostgreSQL via Drizzle). Ne stocke que le contenu quiz
// et l'historique — les rooms vivent en mémoire (voir ARCHITECTURE §3).
import { sql } from "drizzle-orm"
import {
integer,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core"
export const quizCategory = pgTable("quiz_category", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
})
export const quizQuestion = pgTable("quiz_question", {
id: uuid("id").primaryKey().defaultRandom(),
categoryId: uuid("category_id").references(() => quizCategory.id),
/** 'mcq' | 'truefalse' | 'free' | 'image_reveal' */
format: text("format").notNull(),
/** Unique → seed idempotent (onConflictDoNothing). */
prompt: text("prompt").notNull().unique(),
/** 1 (facile) .. 3 (difficile). */
difficulty: integer("difficulty").notNull().default(1),
/** 'opentdb' | 'manual' */
source: text("source").notNull(),
// Champs selon le format (NULL si non applicable) :
choices: jsonb("choices").$type<string[]>(),
correctIndex: integer("correct_index"),
acceptedAnswers: jsonb("accepted_answers").$type<string[]>(),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
})
/** Anti-répétition : ce qu'un groupe (room_code) a déjà joué. */
export const quizPlayed = pgTable(
"quiz_played",
{
questionId: uuid("question_id")
.notNull()
.references(() => quizQuestion.id),
roomCode: text("room_code").notNull(),
playedAt: timestamp("played_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
},
(t) => [primaryKey({ columns: [t.questionId, t.roomCode] })]
)
/** Historique de parties (stats / portfolio). */
export const gameHistory = pgTable("game_history", {
id: uuid("id").primaryKey().defaultRandom(),
roomCode: text("room_code").notNull(),
playedAt: timestamp("played_at", { withTimezone: true })
.notNull()
.default(sql`now()`),
modes: jsonb("modes").$type<string[]>(),
results: jsonb("results"),
})
export type QuizQuestionRow = typeof quizQuestion.$inferSelect
export type NewQuizQuestion = typeof quizQuestion.$inferInsert
export type NewQuizCategory = typeof quizCategory.$inferInsert

193
apps/server/src/db/seed.ts Normal file
View file

@ -0,0 +1,193 @@
// 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 postgres from "postgres"
import { env } from "../env"
import {
quizCategory,
quizQuestion,
type NewQuizQuestion,
} from "./schema"
import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions"
if (!env.databaseUrl) {
console.error("DATABASE_URL manquant — impossible de seeder.")
process.exit(1)
}
const client = postgres(env.databaseUrl, { max: 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",
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",
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",
choices: q.choices,
correctIndex: q.correctIndex,
},
])
}
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()

View file

@ -10,6 +10,8 @@ export const env = {
nodeEnv: process.env.NODE_ENV ?? "development",
host: process.env.HOST ?? "0.0.0.0",
port: num(process.env.PORT, 3001),
/** Connexion PostgreSQL. Vide → pas de DB, fallback banque en dur. */
databaseUrl: process.env.DATABASE_URL ?? "",
/** Origines autorisées pour CORS / Socket.IO (séparées par des virgules). */
corsOrigins: (process.env.CORS_ORIGINS ?? "http://localhost:5173")
.split(",")

View file

@ -2,3 +2,13 @@
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
import "./quiz"
import type { ServerRoom } from "../../rooms"
import { prepareQuizForRoom } from "./quiz/pool"
/** Précharge le contenu nécessaire avant de lancer la partie (ex: pool quiz). */
export async function prepareRoom(room: ServerRoom): Promise<void> {
if (room.settings.rounds.some((r) => r.type === "quiz")) {
await prepareQuizForRoom(room)
}
}

View file

@ -0,0 +1,61 @@
// File d'attente de questions par room. Préchargée au lancement de la partie
// depuis la DB (si dispo), sinon depuis la banque en dur. Le QuizRound y pioche.
import { hasDb } from "../../../db"
import { loadQuizPool } from "../../../db/quiz-repo"
import type { ServerRoom } from "../../../rooms"
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
interface RoomPool {
queue: QuizQuestion[]
/** true si les questions viennent de la DB (→ marquer jouées). */
fromDb: boolean
}
const poolByRoom = new WeakMap<ServerRoom, RoomPool>()
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
}
/** Précharge les questions de la partie. À appeler avant de lancer le moteur. */
export async function prepareQuizForRoom(room: ServerRoom): Promise<void> {
const needed = room.settings.rounds.filter((r) => r.type === "quiz").length
let queue: QuizQuestion[] = []
let fromDb = false
if (hasDb) {
try {
queue = await loadQuizPool(room.code, Math.max(needed, 1))
fromDb = queue.length > 0
} catch (err) {
console.error("[quiz] chargement DB échoué, fallback banque en dur", err)
}
}
if (queue.length === 0) {
queue = shuffle(QUIZ_QUESTIONS)
fromDb = false
}
poolByRoom.set(room, { queue, fromDb })
}
/** Pioche la prochaine question. Fallback aléatoire si la file est vide. */
export function takeQuestion(room: ServerRoom): {
question: QuizQuestion
fromDb: boolean
} {
const pool = poolByRoom.get(room)
if (pool && pool.queue.length > 0) {
return { question: pool.queue.shift()!, fromDb: pool.fromDb }
}
const question =
QUIZ_QUESTIONS[Math.floor(Math.random() * QUIZ_QUESTIONS.length)]
return { question, fromDb: false }
}

View file

@ -8,9 +8,12 @@ import type {
QuizRevealTruth,
ScoreDelta,
} from "@nerdware/shared"
import { hasDb } from "../../../db"
import { markQuestionPlayed } from "../../../db/quiz-repo"
import type { GameRound, RoundContext, RoundStart } from "../../round"
import type { ServerRoom } from "../../../rooms"
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
import type { QuizQuestion } from "./questions"
import { takeQuestion } from "./pool"
const BASE_POINTS = 100
const SPEED_BONUS_MAX = 100
@ -22,26 +25,6 @@ interface QuizRoundData {
votedAt: Map<string, number>
}
// Anti-répétition à l'échelle d'une partie : questions déjà jouées par room.
const playedByRoom = new WeakMap<ServerRoom, Set<string>>()
function pickQuestion(room: ServerRoom): QuizQuestion {
let played = playedByRoom.get(room)
if (!played) {
played = new Set()
playedByRoom.set(room, played)
}
let pool = QUIZ_QUESTIONS.filter((q) => !played.has(q.id))
if (pool.length === 0) {
// Banque épuisée sur cette partie : on autorise à nouveau les répétitions.
played.clear()
pool = QUIZ_QUESTIONS
}
const question = pool[Math.floor(Math.random() * pool.length)]
played.add(question.id)
return question
}
function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } {
return (
typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number"
@ -54,7 +37,11 @@ export class QuizRound implements GameRound {
constructor(private readonly now: () => number = Date.now) {}
start(room: ServerRoom): RoundStart {
const question = pickQuestion(room)
const { question, fromDb } = takeQuestion(room)
// Anti-répétition cross-session : on enregistre la question jouée (DB only).
if (fromDb && hasDb) {
void markQuestionPlayed(room.code, question.id)
}
const payload: QuizQuestionPayload = {
format: question.format,
prompt: question.prompt,

View file

@ -12,6 +12,7 @@ import type { IoServer } from "./socket"
import { RoomError, RoomManager, type ServerRoom } from "./rooms"
import { GameEngine } from "./game/engine"
import { hasRound } from "./game/registry"
import { prepareRoom } from "./game/modes"
type AppSocket = Socket<
ClientToServerEvents,
@ -121,10 +122,15 @@ export function registerRoomHandlers(
return
}
ack({ ok: true, data: null })
const engine = new GameEngine(io, rooms, room)
room.game = engine
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
// Fire-and-forget : la boucle de jeu broadcast son propre état.
void engine.run().catch((err) => console.error("[game] run failed", err))
void prepareRoom(room)
.then(() => {
const engine = new GameEngine(io, rooms, room)
room.game = engine
return engine.run()
})
.catch((err) => console.error("[game] run failed", err))
})
socket.on("round:vote", (payload) => {

125
bun.lock
View file

@ -17,13 +17,16 @@
"dependencies": {
"@fastify/cors": "^11",
"@nerdware/shared": "workspace:*",
"drizzle-orm": "^0.45",
"fastify": "^5",
"postgres": "^3.4",
"socket.io": "^4.8.1",
},
"devDependencies": {
"@eslint/js": "^10",
"@types/bun": "latest",
"@types/node": "^24",
"drizzle-kit": "^0.31",
"eslint": "^10",
"globals": "^17",
"pino-pretty": "^13",
@ -171,6 +174,8 @@
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.71.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
@ -179,6 +184,10 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@ -657,6 +666,8 @@
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@ -747,6 +758,10 @@
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="],
@ -907,6 +922,8 @@
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
@ -1169,6 +1186,8 @@
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@ -1223,6 +1242,8 @@
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
@ -1293,6 +1314,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@ -1347,6 +1370,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
@ -1445,6 +1470,8 @@
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@fastify/ajv-compiler/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
@ -1515,6 +1542,8 @@
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
@ -1539,6 +1568,50 @@
"@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@ -1563,6 +1636,58 @@
"send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
"type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],

24
docker-compose.yml Normal file
View file

@ -0,0 +1,24 @@
# Postgres de développement local pour NerdWare.
# docker compose up -d
# Puis depuis apps/server : bun run db:migrate && bun run db:seed
services:
postgres:
image: postgres:17-alpine
container_name: nerdware-postgres
restart: unless-stopped
environment:
POSTGRES_USER: nerdware
POSTGRES_PASSWORD: nerdware
POSTGRES_DB: nerdware
ports:
- "5432:5432"
volumes:
- nerdware-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nerdware -d nerdware"]
interval: 5s
timeout: 5s
retries: 5
volumes:
nerdware-pgdata: