// Amorçage de la plateforme : cours, compte administrateur initial (hash bcrypt + changement // forcé), prompts versionnés, concepts/cartes/questions (seed-data/*.json), examens blancs. // Idempotent : réexécutable sans dupliquer. import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { all, db, get, getSetting, run, setSetting, transaction } from "../lib/db/index.ts"; import { hashPassword } from "../lib/auth/password.ts"; import { seedPromptsFromFiles } from "../lib/prompts.ts"; db(); // ------------------------------ Cours ------------------------------ const COURSES = [ { code: "IMM1003", full_code: "IMM1003-20", title: "Éléments d'évaluation immobilière", session_label: "Automne 2026", description: "Fondements de l'évaluation immobilière au Québec : cadre professionnel OEAQ, principes économiques, types de valeur, marché québécois, méthodes de comparaison, du coût et du revenu, réconciliation et rapport d'évaluation.", color: "#003E7E", source_path: process.env.COURSE_IMM1003_PATH || "../IMM1003-20", }, { code: "IMM1033", full_code: "IMM1033-20", title: "Méthodes du coût en évaluation immobilière", session_label: "Automne 2026", description: "Maîtrise complète de la méthode du coût : évaluation du terrain, estimation du coût de reproduction ou de remplacement, coûts directs et indirects, profit de l'entrepreneur, mesure des trois formes de dépréciation et applications spécialisées.", color: "#B45309", source_path: process.env.COURSE_IMM1033_PATH || "../IMM1033-20", }, ]; for (const c of COURSES) { run( `INSERT INTO courses (code, full_code, title, session_label, description, color, source_path) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(code) DO UPDATE SET full_code=excluded.full_code, title=excluded.title, session_label=excluded.session_label, description=excluded.description, color=excluded.color, source_path=excluded.source_path`, c.code, c.full_code, c.title, c.session_label, c.description, c.color, c.source_path ); } console.log(`✓ ${COURSES.length} cours`); // ------------------------------ Admin initial ------------------------------ if (process.env.DISABLE_INITIAL_ADMIN === "true") { console.log("• Compte admin initial désactivé (DISABLE_INITIAL_ADMIN=true)"); } else { const username = process.env.INITIAL_ADMIN_USERNAME || "admin"; const password = process.env.INITIAL_ADMIN_PASSWORD || "admin123"; const force = (process.env.FORCE_INITIAL_ADMIN_PASSWORD_CHANGE ?? "true") !== "false"; const existing = get<{ id: number }>("SELECT id FROM users WHERE username = ?", username); if (!existing) { const r = run( `INSERT INTO users (username, display_name, password_hash, role, must_change_password, is_initial_admin) VALUES (?, 'Administrateur', ?, 'admin', ?, 1)`, username, hashPassword(password), force ? 1 : 0 ); const adminId = Number(r.lastInsertRowid); for (const c of COURSES) run("INSERT OR IGNORE INTO enrollments (user_id, course_code) VALUES (?, ?)", adminId, c.code); console.log(`✓ Compte administrateur initial « ${username} » créé (changement de mot de passe ${force ? "FORCÉ à la première connexion" : "non forcé"})`); } else { console.log(`• Compte « ${username} » déjà présent — aucun changement`); } } // ------------------------------ Prompts ------------------------------ seedPromptsFromFiles(); console.log(`✓ Prompts versionnés (${all("SELECT DISTINCT name FROM prompt_versions").length} noms)`); // ------------------------------ Contenu pédagogique ------------------------------ type ConceptSeed = { slug: string; name: string; description: string; week: number; importance: number; axis: string }; type LinkSeed = { from: string; to: string; type: string }; type CardSeed = { concept: string; type: string; front: string; back: string }; type QuestionSeed = { concept: string; type: string; difficulty: number; question: string; options: string[]; answer: string; explanation: string }; function loadJSON(file: string): T | null { const p = resolve(process.cwd(), "seed-data", file); if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, "utf8")) as T; } for (const course of COURSES) { const key = course.code.toLowerCase(); const conceptsData = loadJSON<{ concepts: ConceptSeed[]; links: LinkSeed[] }>(`${key}-concepts.json`); if (!conceptsData) { console.log(`⚠ seed-data/${key}-concepts.json absent — contenu pédagogique de ${course.code} non amorcé`); continue; } transaction(() => { for (const c of conceptsData.concepts) { run( `INSERT INTO concepts (course_code, slug, name, description, week, importance, axis) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(course_code, slug) DO UPDATE SET name=excluded.name, description=excluded.description, week=excluded.week, importance=excluded.importance, axis=excluded.axis`, course.code, c.slug, c.name, c.description, c.week, c.importance, c.axis ); } const idOf = new Map( all<{ id: number; slug: string }>("SELECT id, slug FROM concepts WHERE course_code = ?", course.code).map((r) => [r.slug, r.id]) ); for (const l of conceptsData.links ?? []) { const from = idOf.get(l.from), to = idOf.get(l.to); if (from && to) run("INSERT OR IGNORE INTO concept_links (from_id, to_id, type) VALUES (?, ?, ?)", from, to, l.type); } const cards = loadJSON<{ flashcards: CardSeed[] }>(`${key}-flashcards.json`); if (cards) { const already = get<{ n: number }>("SELECT COUNT(*) as n FROM flashcards WHERE course_code = ? AND created_by = 'seed'", course.code); if ((already?.n ?? 0) === 0) { for (const f of cards.flashcards) { run( `INSERT INTO flashcards (course_code, concept_id, type, front, back, created_by, validated) VALUES (?, ?, ?, ?, ?, 'seed', 1)`, course.code, idOf.get(f.concept) ?? null, f.type, f.front, f.back ); } console.log(`✓ ${course.code} : ${cards.flashcards.length} cartes mémoire`); } } const quiz = loadJSON<{ questions: QuestionSeed[] }>(`${key}-quiz.json`); if (quiz) { const already = get<{ n: number }>("SELECT COUNT(*) as n FROM quiz_questions WHERE course_code = ? AND created_by = 'seed'", course.code); if ((already?.n ?? 0) === 0) { for (const q of quiz.questions) { run( `INSERT INTO quiz_questions (course_code, concept_id, type, difficulty, question, options, answer, explanation, created_by, validated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'seed', 1)`, course.code, idOf.get(q.concept) ?? null, q.type, q.difficulty, q.question, JSON.stringify(q.options ?? []), q.answer, q.explanation ); } console.log(`✓ ${course.code} : ${quiz.questions.length} questions de quiz`); } } }); console.log(`✓ ${course.code} : ${conceptsData.concepts.length} concepts, ${(conceptsData.links ?? []).length} liens`); } // Lien inter-cours : la méthode du coût d'IMM1003 (survol) est approfondie par IMM1033. const c1003cout = get<{ id: number }>( "SELECT id FROM concepts WHERE course_code = 'IMM1003' AND (slug LIKE '%cout%' OR slug LIKE '%coût%') ORDER BY importance DESC LIMIT 1" ); const c1033roots = all<{ id: number }>( "SELECT id FROM concepts WHERE course_code = 'IMM1033' AND week <= 2 ORDER BY importance DESC LIMIT 2" ); if (c1003cout && c1033roots.length) { for (const r of c1033roots) { run("INSERT OR IGNORE INTO concept_links (from_id, to_id, type) VALUES (?, ?, 'approfondissement')", r.id, c1003cout.id); } } // ------------------------------ Examens blancs ------------------------------ type ExamSpec = { course: string; kind: string; title: string; description: string; duration: number; weeks: [number, number]; n: number }; const EXAMS: ExamSpec[] = [ { course: "IMM1003", kind: "intra", title: "Intra blanc — Fondements et marché (S1-6)", description: "Simulation de l'examen de mi-session : fondements, cadre professionnel, principes, valeur, marché, collecte de données.", duration: 120, weeks: [1, 6], n: 18 }, { course: "IMM1003", kind: "final", title: "Final blanc — Les trois méthodes (S1-13, accent 8-13)", description: "Simulation de l'examen final : méthodes de comparaison, du coût et du revenu, réconciliation, rapport.", duration: 170, weeks: [1, 13], n: 24 }, { course: "IMM1003", kind: "thematic", title: "Examen thématique — Méthode du revenu", description: "RBP→RNE, capitalisation directe, TGA, DCF, multiplicateurs.", duration: 75, weeks: [11, 12], n: 12 }, { course: "IMM1033", kind: "intra", title: "Intra blanc — Terrain et coûts (S1-8)", description: "Simulation de l'intra : fondements, évaluation du terrain, estimation des coûts directs et indirects.", duration: 120, weeks: [1, 8], n: 18 }, { course: "IMM1033", kind: "final", title: "Final blanc — Dépréciation et intégration (S1-14)", description: "Simulation du final : les trois dépréciations, applications spécialisées, cas intégrateur.", duration: 170, weeks: [1, 14], n: 24 }, { course: "IMM1033", kind: "thematic", title: "Examen thématique — Les trois dépréciations", description: "Physique, fonctionnelle, économique : classification, mesure, non-double-comptage.", duration: 75, weeks: [9, 12], n: 12 }, ]; for (const e of EXAMS) { const exists = get("SELECT id FROM mock_exams WHERE course_code = ? AND kind = ? AND title = ?", e.course, e.kind, e.title); if (exists) continue; // Sélection pondérée : questions des semaines visées, difficultés étalées, accent sur la fin de portée pour les finaux. const qs = all<{ id: number; difficulty: number; week: number | null }>( `SELECT q.id, q.difficulty, c.week FROM quiz_questions q LEFT JOIN concepts c ON c.id = q.concept_id WHERE q.course_code = ? AND (c.week IS NULL OR (c.week >= ? AND c.week <= ?)) ORDER BY RANDOM()`, e.course, e.weeks[0], e.weeks[1] ); if (qs.length < Math.min(8, e.n)) { console.log(`⚠ ${e.title} : banque insuffisante (${qs.length} questions) — examen non créé`); continue; } // Étaler les difficultés : trier par difficulté et prendre en alternance. const sorted = [...qs].sort((a, b) => a.difficulty - b.difficulty); const picked: number[] = []; const step = sorted.length / Math.min(e.n, sorted.length); for (let i = 0; i < Math.min(e.n, sorted.length); i++) picked.push(sorted[Math.floor(i * step)].id); run( `INSERT INTO mock_exams (course_code, kind, title, description, duration_minutes, question_ids, config, created_by, official) VALUES (?, ?, ?, ?, ?, ?, ?, 'seed', 1)`, e.course, e.kind, e.title, e.description, e.duration, JSON.stringify(picked), JSON.stringify({ weeks: e.weeks }) ); console.log(`✓ Examen blanc : ${e.title} (${picked.length} questions)`); } // ------------------------------ Paramètres par défaut ------------------------------ if (getSetting("integrity_policy", null) === null) { setSetting("integrity_policy", { enabled: true, examLockdown: false, allowedModesDuringLockdown: ["tutor", "socratic"] }); } if (getSetting("cross_course_enabled", null) === null) setSetting("cross_course_enabled", false); if (getSetting("sharing_enabled", null) === null) setSetting("sharing_enabled", true); console.log("✓ Paramètres par défaut"); const counts = { users: get<{ n: number }>("SELECT COUNT(*) as n FROM users")?.n, concepts: get<{ n: number }>("SELECT COUNT(*) as n FROM concepts")?.n, flashcards: get<{ n: number }>("SELECT COUNT(*) as n FROM flashcards")?.n, questions: get<{ n: number }>("SELECT COUNT(*) as n FROM quiz_questions")?.n, exams: get<{ n: number }>("SELECT COUNT(*) as n FROM mock_exams")?.n, }; console.log("— Seed terminé :", JSON.stringify(counts));