diff --git a/backend/scripts/create-bisaya-course-content.js b/backend/scripts/create-bisaya-course-content.js index 4066fcc..0e6461d 100644 --- a/backend/scripts/create-bisaya-course-content.js +++ b/backend/scripts/create-bisaya-course-content.js @@ -5169,6 +5169,10 @@ async function findOrCreateSystemUser() { function getExercisesForLesson(lesson) { const lessonTitle = lesson.title; + // Alte Kurstitel (DB noch nicht migriert) + if (lessonTitle === 'Zahlen & Preise' && BISAYA_EXERCISES['Zahlen 1–20']) { + return BISAYA_EXERCISES['Zahlen 1–20']; + } // Suche nach exaktem Titel if (BISAYA_EXERCISES[lessonTitle]) { return BISAYA_EXERCISES[lessonTitle]; @@ -5254,6 +5258,7 @@ async function createBisayaCourseContent() { 'Zeitformen - Grundlagen', 'Zeit & Datum', 'Einkaufen & Preise', + 'Zahlen & Preise', 'Zahlen 1–20', 'Zahlen: Zehner', 'Zahlen: Hunderter', diff --git a/backend/scripts/create-bisaya-course.js b/backend/scripts/create-bisaya-course.js index 4a23573..fa501f2 100755 --- a/backend/scripts/create-bisaya-course.js +++ b/backend/scripts/create-bisaya-course.js @@ -1076,6 +1076,8 @@ const LESSONS = [ ...BISAYA_PHASE5_LESSONS ]; +const BISAYA_FAMILY_COURSE_TITLE = 'Bisaya für Familien - Alltag & Stabilisierung'; + async function createBisayaCourse(languageId, ownerHashedId) { try { // Finde User @@ -1093,11 +1095,25 @@ async function createBisayaCourse(languageId, ownerHashedId) { throw new Error(`Sprache mit ID ${languageId} nicht gefunden`); } + const existing = await VocabCourse.findOne({ + where: { + languageId: Number(languageId), + title: BISAYA_FAMILY_COURSE_TITLE + } + }); + if (existing) { + console.log( + `⏭️ Es existiert bereits ein „Familien“-Bisaya-Kurs für diese Zielsprache (language_id=${languageId}, Kurs-ID ${existing.id}).` + ); + console.log(' Es wird kein zweiter Kurs angelegt (idempotent).'); + return existing; + } + // Erstelle Kurs const shareCode = crypto.randomBytes(8).toString('hex'); const course = await VocabCourse.create({ ownerUserId: user.id, - title: 'Bisaya für Familien - Alltag & Stabilisierung', + title: BISAYA_FAMILY_COURSE_TITLE, description: 'Lerne Bisaya (Cebuano) praxisnah für den Familienalltag. Der Pfad verbindet Schnellstart, Alltagsmodule und Stabilisierungsblöcke mit Spiralwiederholung, Fehlertraining und freier Produktion.', languageId: Number(languageId), difficultyLevel: 1, diff --git a/backend/scripts/create-language-courses.js b/backend/scripts/create-language-courses.js index 9c820b6..1cea1ae 100755 --- a/backend/scripts/create-language-courses.js +++ b/backend/scripts/create-language-courses.js @@ -5,10 +5,13 @@ * Verwendung: * node backend/scripts/create-language-courses.js * - * Erstellt öffentliche Kurse für alle Kombinationen von: + * Erstellt öffentliche Kurse für Kombinationen aus Ziel- und Muttersprache: * - Zielsprachen: Bisaya, Französisch, Spanisch, Latein, Italienisch, Portugiesisch, Tagalog * - Muttersprachen: Deutsch, Englisch, Spanisch, Französisch, Italienisch, Portugiesisch - * + * + * Ausnahme Bisaya: Es wird nur ein Schnellstart-Kurs „Bisaya für Deutschsprachige“ erzeugt + * (Zielgruppe der Plattform). Andere Muttersprachen-Kombinationen für Bisaya entfallen. + * * Die Kurse werden automatisch einem System-Benutzer zugeordnet und sind öffentlich zugänglich. */ @@ -271,7 +274,10 @@ function generateCourseConfigs() { for (const nativeLang of NATIVE_LANGUAGES) { // Überspringe, wenn Zielsprache = Muttersprache if (targetLang === nativeLang) continue; - + + // Bisaya: nur Kurs für deutschsprachige Lernende (eine Zielsprache, eine Muttersprache) + if (targetLang === 'Bisaya' && nativeLang !== 'Deutsch') continue; + const title = `${targetLang} für ${nativeLang}sprachige - Schnellstart in 4 Wochen`; let description = `Lerne ${targetLang} schnell und praktisch für den Alltag. `; diff --git a/backend/scripts/dedupe-bisaya-family-courses.js b/backend/scripts/dedupe-bisaya-family-courses.js new file mode 100644 index 0000000..0aa9272 --- /dev/null +++ b/backend/scripts/dedupe-bisaya-family-courses.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * Entfernt doppelte „Bisaya für Familien - Alltag & Stabilisierung“-Kurse und behält + * den Kurs mit der kleinsten ID (üblicherweise der erste / in der UI verlinkte). + * + * Standard: Nur Anzeige (Dry-Run). Löschen nur mit: + * VOCAB_DEDUPE_BISAYA_FAMILY_APPLY=1 node backend/scripts/dedupe-bisaya-family-courses.js + */ + +import { sequelize } from '../utils/sequelize.js'; + +const CANONICAL_TITLE = 'Bisaya für Familien - Alltag & Stabilisierung'; + +async function deleteCourseCascade(courseId, transaction) { + const lessonRows = await sequelize.query( + `SELECT id FROM community.vocab_course_lesson WHERE course_id = :cid`, + { replacements: { cid: courseId }, transaction, type: sequelize.QueryTypes.SELECT } + ); + const lessonIds = lessonRows.map((r) => r.id); + + if (lessonIds.length > 0) { + const exRows = await sequelize.query( + `SELECT id FROM community.vocab_grammar_exercise WHERE lesson_id IN (:lids)`, + { replacements: { lids: lessonIds }, transaction, type: sequelize.QueryTypes.SELECT } + ); + const exerciseIds = exRows.map((r) => r.id); + + if (exerciseIds.length > 0) { + await sequelize.query( + `DELETE FROM community.vocab_grammar_exercise_progress WHERE exercise_id IN (:eids)`, + { replacements: { eids: exerciseIds }, transaction } + ); + await sequelize.query(`DELETE FROM community.vocab_grammar_exercise WHERE id IN (:eids)`, { + replacements: { eids: exerciseIds }, + transaction + }); + } + + await sequelize.query( + `DELETE FROM community.vocab_course_progress WHERE lesson_id IN (:lids) OR course_id = :cid`, + { replacements: { lids: lessonIds, cid: courseId }, transaction } + ); + await sequelize.query( + `DELETE FROM community.vocab_course_lesson WHERE course_id = :cid`, + { replacements: { cid: courseId }, transaction } + ); + } else { + await sequelize.query( + `DELETE FROM community.vocab_course_progress WHERE course_id = :cid`, + { replacements: { cid: courseId }, transaction } + ); + } + + await sequelize.query( + `DELETE FROM community.vocab_course_enrollment WHERE course_id = :cid`, + { replacements: { cid: courseId }, transaction } + ); + await sequelize.query(`DELETE FROM community.vocab_course WHERE id = :cid`, { + replacements: { cid: courseId }, + transaction + }); +} + +async function main() { + const apply = process.env.VOCAB_DEDUPE_BISAYA_FAMILY_APPLY === '1'; + await sequelize.authenticate(); + + const [bisayaLanguage] = await sequelize.query( + `SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`, + { type: sequelize.QueryTypes.SELECT } + ); + if (!bisayaLanguage) { + console.error('❌ Bisaya-Sprache nicht gefunden.'); + process.exit(1); + } + + const courses = await sequelize.query( + `SELECT id, title, owner_user_id AS "ownerUserId", created_at AS "createdAt" + FROM community.vocab_course + WHERE language_id = :languageId AND title = :title + ORDER BY id ASC`, + { + replacements: { languageId: bisayaLanguage.id, title: CANONICAL_TITLE }, + type: sequelize.QueryTypes.SELECT + } + ); + + if (courses.length <= 1) { + console.log( + courses.length === 0 + ? `Kein Kurs mit Titel „${CANONICAL_TITLE}“ gefunden.` + : `Nur ein Kurs – nichts zu bereinigen (ID ${courses[0].id}).` + ); + await sequelize.close(); + return; + } + + const keep = courses[0]; + const remove = courses.slice(1); + + console.log(`Behalten: Kurs-ID ${keep.id} (älteste ID)\n`); + console.log(`${apply ? 'Löschen' : 'Würde löschen'} (${remove.length} Duplikate):`); + for (const c of remove) { + const stat = await sequelize.query( + `SELECT + (SELECT COUNT(*)::int FROM community.vocab_course_enrollment WHERE course_id = :cid) AS enroll, + (SELECT COUNT(*)::int FROM community.vocab_course_progress WHERE course_id = :cid) AS prog`, + { replacements: { cid: c.id }, type: sequelize.QueryTypes.SELECT } + ); + const row = stat[0]; + console.log( + ` – ID ${c.id} (owner ${c.ownerUserId}, Enrollments ${row?.enroll ?? '?'}, Fortschrittszeilen ${row?.prog ?? '?'})` + ); + } + + if (!apply) { + console.log( + '\nDry-Run. Zum tatsächlichen Löschen: VOCAB_DEDUPE_BISAYA_FAMILY_APPLY=1 node backend/scripts/dedupe-bisaya-family-courses.js' + ); + await sequelize.close(); + return; + } + + await sequelize.transaction(async (t) => { + for (const c of remove) { + await deleteCourseCascade(c.id, t); + console.log(`🗑️ Kurs ${c.id} entfernt.`); + } + }); + + console.log(`\n✅ Fertig. Verbleibender Kurs: ID ${keep.id}`); + await sequelize.close(); +} + +main().catch((e) => { + console.error('❌ Fehler:', e); + sequelize.close(); + process.exit(1); +}); diff --git a/backend/scripts/migrate-bisaya-zahlen-split.js b/backend/scripts/migrate-bisaya-zahlen-split.js new file mode 100644 index 0000000..3298bed --- /dev/null +++ b/backend/scripts/migrate-bisaya-zahlen-split.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * Migriert ältere Bisaya-Kurse von einer kombinierten Lektion „Zahlen & Preise“ (nur #18) + * zur viergeteilten Zahlenreihe (#18–#21). Erwartet vorher noch das alte Layout: + * Lektion 19 = „Woche 2 - Wiederholung“ (liegt im neuen Kurs an Position 22). + * + * Ablauf je Kurs: + * - Lektionen mit lesson_number >= 19 um +3 nach oben schieben (von oben nach unten) + * - Lektion 18 auf „Zahlen 1–20“ setzen + * - Neue Lektionen 19–21 („Zahlen: Zehner/Hunderter/Tausender“) einfügen + * + * Verwendung: + * node backend/scripts/migrate-bisaya-zahlen-split.js + * + * Anschließend: + * node backend/scripts/update-bisaya-didactics.js + * node backend/scripts/create-bisaya-course-content.js + */ + +import { sequelize } from '../utils/sequelize.js'; +import VocabCourseLesson from '../models/community/vocab_course_lesson.js'; +import { LESSON_DIDACTICS } from './update-bisaya-didactics.js'; +import { getBisayaLessonPedagogy } from './bisaya-course-phase2-pedagogy.js'; + +const INSERTED_LESSONS = [ + { + lessonNumber: 19, + title: 'Zahlen: Zehner', + description: 'Runde Zehner von 20 bis 90', + weekNumber: 2, + dayNumber: 4, + lessonType: 'vocab', + culturalNotes: null, + targetMinutes: 20, + targetScorePercent: 85, + requiresReview: true + }, + { + lessonNumber: 20, + title: 'Zahlen: Hunderter', + description: 'Hunderter bis 900 (usa ka gatos … siyam ka gatos)', + weekNumber: 2, + dayNumber: 4, + lessonType: 'vocab', + culturalNotes: null, + targetMinutes: 20, + targetScorePercent: 85, + requiresReview: true + }, + { + lessonNumber: 21, + title: 'Zahlen: Tausender', + description: 'Tausender und große Beträge (libo)', + weekNumber: 2, + dayNumber: 4, + lessonType: 'vocab', + culturalNotes: null, + targetMinutes: 18, + targetScorePercent: 85, + requiresReview: true + } +]; + +function applyPedagogy(patch, lessonNumber) { + const pedagogy = getBisayaLessonPedagogy(lessonNumber) || {}; + patch.didacticMode = pedagogy.didacticMode || null; + patch.phaseLabel = pedagogy.phaseLabel || null; + patch.blockNumber = pedagogy.blockNumber ?? null; + patch.difficultyWeight = pedagogy.difficultyWeight ?? null; + patch.newUnitTarget = pedagogy.newUnitTarget ?? null; + patch.reviewWeight = pedagogy.reviewWeight ?? null; + patch.isIntensiveReview = Boolean(pedagogy.isIntensiveReview); +} + +function applyDidactics(patch, title) { + const d = LESSON_DIDACTICS[title]; + if (!d) return; + patch.learningGoals = d.learningGoals || []; + patch.corePatterns = d.corePatterns || []; + patch.grammarFocus = d.grammarFocus || []; + patch.speakingPrompts = d.speakingPrompts || []; + patch.practicalTasks = d.practicalTasks || []; +} + +async function migrateCourse(courseId) { + const maxRow = await sequelize.query( + `SELECT MAX(lesson_number) AS max FROM community.vocab_course_lesson WHERE course_id = :courseId`, + { replacements: { courseId }, type: sequelize.QueryTypes.SELECT } + ); + const maxNum = Number(maxRow[0]?.max || 0); + if (maxNum < 19) { + return { status: 'skip', reason: 'weniger als 19 Lektionen' }; + } + + const l19 = await VocabCourseLesson.findOne({ + where: { courseId, lessonNumber: 19 } + }); + if (!l19) { + return { status: 'skip', reason: 'keine Lektion 19' }; + } + if (l19.title !== 'Woche 2 - Wiederholung') { + return { status: 'skip', reason: 'Lektion 19 ist nicht „Woche 2 - Wiederholung“ (bereits migriert oder anderer Stand)' }; + } + + const l18 = await VocabCourseLesson.findOne({ + where: { courseId, lessonNumber: 18 } + }); + if (!l18) { + return { status: 'skip', reason: 'keine Lektion 18' }; + } + if (l18.title !== 'Zahlen & Preise' && l18.title !== 'Zahlen 1–20') { + return { status: 'skip', reason: `Lektion 18 unerwartet: „${l18.title}“` }; + } + + await sequelize.transaction(async (t) => { + for (let n = maxNum; n >= 19; n -= 1) { + await VocabCourseLesson.update( + { lessonNumber: n + 3 }, + { where: { courseId, lessonNumber: n }, transaction: t } + ); + } + + const lesson18 = await VocabCourseLesson.findOne({ + where: { courseId, lessonNumber: 18 }, + transaction: t + }); + const patch18 = { + title: 'Zahlen 1–20', + description: 'Grundzahlen und Zahlen bis 20 (usa … baynte)', + weekNumber: 2, + dayNumber: 4, + lessonType: 'vocab', + culturalNotes: null, + targetMinutes: 22, + targetScorePercent: 85, + requiresReview: true + }; + applyDidactics(patch18, 'Zahlen 1–20'); + applyPedagogy(patch18, 18); + await lesson18.update(patch18, { transaction: t }); + + for (const def of INSERTED_LESSONS) { + const createPayload = { + courseId, + chapterId: null, + lessonNumber: def.lessonNumber, + title: def.title, + description: def.description, + weekNumber: def.weekNumber, + dayNumber: def.dayNumber, + lessonType: def.lessonType, + culturalNotes: def.culturalNotes, + targetMinutes: def.targetMinutes, + targetScorePercent: def.targetScorePercent, + requiresReview: def.requiresReview + }; + applyDidactics(createPayload, def.title); + applyPedagogy(createPayload, def.lessonNumber); + await VocabCourseLesson.create(createPayload, { transaction: t }); + } + }); + + return { status: 'ok' }; +} + +async function main() { + await sequelize.authenticate(); + + const [bisayaLanguage] = await sequelize.query( + `SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`, + { type: sequelize.QueryTypes.SELECT } + ); + if (!bisayaLanguage) { + console.error('❌ Bisaya-Sprache nicht gefunden.'); + process.exit(1); + } + + const courses = await sequelize.query( + `SELECT id, title FROM community.vocab_course WHERE language_id = :languageId ORDER BY id`, + { replacements: { languageId: bisayaLanguage.id }, type: sequelize.QueryTypes.SELECT } + ); + + console.log(`Gefunden: ${courses.length} Bisaya-Kurs(e)\n`); + + for (const course of courses) { + const result = await migrateCourse(course.id); + if (result.status === 'ok') { + console.log(`✅ Kurs ${course.id}: „${course.title}“ – Zahlen-Split angewendet (Lektionen verschoben, 19–21 eingefügt).`); + } else { + console.log(`⏭️ Kurs ${course.id}: „${course.title}“ – ${result.reason}`); + } + } + + console.log('\nFertig. Danach: node backend/scripts/update-bisaya-didactics.js && node backend/scripts/create-bisaya-course-content.js'); + await sequelize.close(); +} + +main().catch((e) => { + console.error('❌ Fehler:', e); + sequelize.close(); + process.exit(1); +}); diff --git a/backend/scripts/update-bisaya-didactics.js b/backend/scripts/update-bisaya-didactics.js index c414536..5fa9092 100644 --- a/backend/scripts/update-bisaya-didactics.js +++ b/backend/scripts/update-bisaya-didactics.js @@ -10,7 +10,12 @@ import { sequelize } from '../utils/sequelize.js'; import VocabCourseLesson from '../models/community/vocab_course_lesson.js'; import { getBisayaLessonPedagogy } from './bisaya-course-phase2-pedagogy.js'; -const LESSON_DIDACTICS = { +/** Alte Kurstitel → aktueller Schlüssel in LESSON_DIDACTICS (bestehende Datenbanken). */ +export const LEGACY_DIDACTICS_TITLE_MAP = { + 'Zahlen & Preise': 'Zahlen 1–20' +}; + +export const LESSON_DIDACTICS = { 'Begrüßungen & Höflichkeit': { learningGoals: [ 'Einfache Begrüßungen verstehen und selbst verwenden.', @@ -438,6 +443,14 @@ const LESSON_DIDACTICS = { } }; +function resolveDidacticsForLesson(lesson) { + const direct = LESSON_DIDACTICS[lesson.title]; + if (direct) return direct; + const mappedTitle = LEGACY_DIDACTICS_TITLE_MAP[lesson.title]; + if (mappedTitle) return LESSON_DIDACTICS[mappedTitle]; + return null; +} + async function updateBisayaDidactics() { await sequelize.authenticate(); const [bisayaLanguage] = await sequelize.query( @@ -464,25 +477,29 @@ async function updateBisayaDidactics() { let updated = 0; for (const row of lessons) { const lesson = await VocabCourseLesson.findByPk(row.id); - const didactics = LESSON_DIDACTICS[lesson.title]; + const didactics = resolveDidacticsForLesson(lesson); const pedagogy = getBisayaLessonPedagogy(lesson.lessonNumber); if (!didactics && !pedagogy) continue; - const normalizedDidactics = didactics || {}; - await lesson.update({ - learningGoals: normalizedDidactics.learningGoals || [], - corePatterns: normalizedDidactics.corePatterns || [], - grammarFocus: normalizedDidactics.grammarFocus || [], - speakingPrompts: normalizedDidactics.speakingPrompts || [], - practicalTasks: normalizedDidactics.practicalTasks || [], - didacticMode: pedagogy?.didacticMode || null, - phaseLabel: pedagogy?.phaseLabel || null, - blockNumber: pedagogy?.blockNumber ?? null, - difficultyWeight: pedagogy?.difficultyWeight ?? null, - newUnitTarget: pedagogy?.newUnitTarget ?? null, - reviewWeight: pedagogy?.reviewWeight ?? null, - isIntensiveReview: Boolean(pedagogy?.isIntensiveReview) - }); + const patch = {}; + if (didactics) { + patch.learningGoals = didactics.learningGoals || []; + patch.corePatterns = didactics.corePatterns || []; + patch.grammarFocus = didactics.grammarFocus || []; + patch.speakingPrompts = didactics.speakingPrompts || []; + patch.practicalTasks = didactics.practicalTasks || []; + } + if (pedagogy) { + patch.didacticMode = pedagogy.didacticMode || null; + patch.phaseLabel = pedagogy.phaseLabel || null; + patch.blockNumber = pedagogy.blockNumber ?? null; + patch.difficultyWeight = pedagogy.difficultyWeight ?? null; + patch.newUnitTarget = pedagogy.newUnitTarget ?? null; + patch.reviewWeight = pedagogy.reviewWeight ?? null; + patch.isIntensiveReview = Boolean(pedagogy.isIntensiveReview); + } + + await lesson.update(patch); updated++; console.log(`✅ Didaktik aktualisiert: Lektion ${lesson.lessonNumber} - ${lesson.title}`); }