diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index d4d4e44..cefa05a 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -21,6 +21,37 @@ class VocabController { this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId)); this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId)); this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 }); + + // Courses + this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 }); + this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query)); + this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId)); + this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body)); + this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId)); + + // Lessons + this.addLessonToCourse = this._wrapWithUser((userId, req) => this.service.addLessonToCourse(userId, req.params.courseId, req.body), { successStatus: 201 }); + this.updateLesson = this._wrapWithUser((userId, req) => this.service.updateLesson(userId, req.params.lessonId, req.body)); + this.deleteLesson = this._wrapWithUser((userId, req) => this.service.deleteLesson(userId, req.params.lessonId)); + + // Enrollment + this.enrollInCourse = this._wrapWithUser((userId, req) => this.service.enrollInCourse(userId, req.params.courseId), { successStatus: 201 }); + this.unenrollFromCourse = this._wrapWithUser((userId, req) => this.service.unenrollFromCourse(userId, req.params.courseId)); + this.getMyCourses = this._wrapWithUser((userId) => this.service.getMyCourses(userId)); + + // Progress + this.getCourseProgress = this._wrapWithUser((userId, req) => this.service.getCourseProgress(userId, req.params.courseId)); + this.updateLessonProgress = this._wrapWithUser((userId, req) => this.service.updateLessonProgress(userId, req.params.lessonId, req.body)); + + // Grammar Exercises + this.getExerciseTypes = this._wrapWithUser((userId) => this.service.getExerciseTypes()); + this.createGrammarExercise = this._wrapWithUser((userId, req) => this.service.createGrammarExercise(userId, req.params.lessonId, req.body), { successStatus: 201 }); + this.getGrammarExercisesForLesson = this._wrapWithUser((userId, req) => this.service.getGrammarExercisesForLesson(userId, req.params.lessonId)); + this.getGrammarExercise = this._wrapWithUser((userId, req) => this.service.getGrammarExercise(userId, req.params.exerciseId)); + this.checkGrammarExerciseAnswer = this._wrapWithUser((userId, req) => this.service.checkGrammarExerciseAnswer(userId, req.params.exerciseId, req.body.answer)); + this.getGrammarExerciseProgress = this._wrapWithUser((userId, req) => this.service.getGrammarExerciseProgress(userId, req.params.lessonId)); + this.updateGrammarExercise = this._wrapWithUser((userId, req) => this.service.updateGrammarExercise(userId, req.params.exerciseId, req.body)); + this.deleteGrammarExercise = this._wrapWithUser((userId, req) => this.service.deleteGrammarExercise(userId, req.params.exerciseId)); } _wrapWithUser(fn, { successStatus = 200 } = {}) { diff --git a/backend/migrations/20260115000000-add-vocab-courses.cjs b/backend/migrations/20260115000000-add-vocab-courses.cjs new file mode 100644 index 0000000..f60ab25 --- /dev/null +++ b/backend/migrations/20260115000000-add-vocab-courses.cjs @@ -0,0 +1,132 @@ +/* eslint-disable */ +'use strict'; + +module.exports = { + async up(queryInterface) { + // Kurs-Tabelle + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_course ( + id SERIAL PRIMARY KEY, + owner_user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + language_id INTEGER NOT NULL, + difficulty_level INTEGER DEFAULT 1, + is_public BOOLEAN DEFAULT false, + share_code TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_owner_fk + FOREIGN KEY (owner_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_share_code_uniq UNIQUE (share_code) + ); + `); + + // Lektionen innerhalb eines Kurses + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_course_lesson ( + id SERIAL PRIMARY KEY, + course_id INTEGER NOT NULL, + chapter_id INTEGER NOT NULL, + lesson_number INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_lesson_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_lesson_chapter_fk + FOREIGN KEY (chapter_id) + REFERENCES community.vocab_chapter(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_lesson_unique UNIQUE (course_id, lesson_number) + ); + `); + + // Einschreibungen in Kurse + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_course_enrollment ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + course_id INTEGER NOT NULL, + enrolled_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_enrollment_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_enrollment_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_enrollment_unique UNIQUE (user_id, course_id) + ); + `); + + // Fortschritt pro User und Lektion + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_course_progress ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + course_id INTEGER NOT NULL, + lesson_id INTEGER NOT NULL, + completed BOOLEAN DEFAULT false, + score INTEGER DEFAULT 0, + last_accessed_at TIMESTAMP WITHOUT TIME ZONE, + completed_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT vocab_course_progress_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_lesson_fk + FOREIGN KEY (lesson_id) + REFERENCES community.vocab_course_lesson(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_unique UNIQUE (user_id, lesson_id) + ); + `); + + // Indizes + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_course_owner_idx + ON community.vocab_course(owner_user_id); + CREATE INDEX IF NOT EXISTS vocab_course_language_idx + ON community.vocab_course(language_id); + CREATE INDEX IF NOT EXISTS vocab_course_public_idx + ON community.vocab_course(is_public); + CREATE INDEX IF NOT EXISTS vocab_course_lesson_course_idx + ON community.vocab_course_lesson(course_id); + CREATE INDEX IF NOT EXISTS vocab_course_lesson_chapter_idx + ON community.vocab_course_lesson(chapter_id); + CREATE INDEX IF NOT EXISTS vocab_course_enrollment_user_idx + ON community.vocab_course_enrollment(user_id); + CREATE INDEX IF NOT EXISTS vocab_course_enrollment_course_idx + ON community.vocab_course_enrollment(course_id); + CREATE INDEX IF NOT EXISTS vocab_course_progress_user_idx + ON community.vocab_course_progress(user_id); + CREATE INDEX IF NOT EXISTS vocab_course_progress_course_idx + ON community.vocab_course_progress(course_id); + CREATE INDEX IF NOT EXISTS vocab_course_progress_lesson_idx + ON community.vocab_course_progress(lesson_id); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP TABLE IF EXISTS community.vocab_course_progress CASCADE; + DROP TABLE IF EXISTS community.vocab_course_enrollment CASCADE; + DROP TABLE IF EXISTS community.vocab_course_lesson CASCADE; + DROP TABLE IF EXISTS community.vocab_course CASCADE; + `); + } +}; diff --git a/backend/migrations/20260115000001-add-vocab-grammar-exercises.cjs b/backend/migrations/20260115000001-add-vocab-grammar-exercises.cjs new file mode 100644 index 0000000..c70480a --- /dev/null +++ b/backend/migrations/20260115000001-add-vocab-grammar-exercises.cjs @@ -0,0 +1,101 @@ +/* eslint-disable */ +'use strict'; + +module.exports = { + async up(queryInterface) { + // Grammatik-Übungstypen (z.B. "gap_fill", "multiple_choice", "sentence_building", "transformation") + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW() + ); + `); + + // Grammatik-Übungen (verknüpft mit Lektionen) + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise ( + id SERIAL PRIMARY KEY, + lesson_id INTEGER NOT NULL, + exercise_type_id INTEGER NOT NULL, + exercise_number INTEGER NOT NULL, + title TEXT NOT NULL, + instruction TEXT, + question_data JSONB NOT NULL, + answer_data JSONB NOT NULL, + explanation TEXT, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_grammar_exercise_lesson_fk + FOREIGN KEY (lesson_id) + REFERENCES community.vocab_course_lesson(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_type_fk + FOREIGN KEY (exercise_type_id) + REFERENCES community.vocab_grammar_exercise_type(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number) + ); + `); + + // Fortschritt für Grammatik-Übungen + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + exercise_id INTEGER NOT NULL, + attempts INTEGER DEFAULT 0, + correct_attempts INTEGER DEFAULT 0, + last_attempt_at TIMESTAMP WITHOUT TIME ZONE, + completed BOOLEAN DEFAULT false, + completed_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT vocab_grammar_exercise_progress_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_progress_exercise_fk + FOREIGN KEY (exercise_id) + REFERENCES community.vocab_grammar_exercise(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id) + ); + `); + + // Indizes + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx + ON community.vocab_grammar_exercise(lesson_id); + CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx + ON community.vocab_grammar_exercise(exercise_type_id); + CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx + ON community.vocab_grammar_exercise_progress(user_id); + CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx + ON community.vocab_grammar_exercise_progress(exercise_id); + `); + + // Standard-Übungstypen einfügen + await queryInterface.sequelize.query(` + INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES + ('gap_fill', 'Lückentext-Übung'), + ('multiple_choice', 'Multiple-Choice-Fragen'), + ('sentence_building', 'Satzbau-Übung'), + ('transformation', 'Satzumformung'), + ('conjugation', 'Konjugations-Übung'), + ('declension', 'Deklinations-Übung') + ON CONFLICT (name) DO NOTHING; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP TABLE IF EXISTS community.vocab_grammar_exercise_progress CASCADE; + DROP TABLE IF EXISTS community.vocab_grammar_exercise CASCADE; + DROP TABLE IF EXISTS community.vocab_grammar_exercise_type CASCADE; + `); + } +}; diff --git a/backend/migrations/20260115000002-add-course-structure.cjs b/backend/migrations/20260115000002-add-course-structure.cjs new file mode 100644 index 0000000..b12ee91 --- /dev/null +++ b/backend/migrations/20260115000002-add-course-structure.cjs @@ -0,0 +1,47 @@ +/* eslint-disable */ +'use strict'; + +module.exports = { + async up(queryInterface) { + // chapter_id optional machen (nicht alle Lektionen brauchen ein Kapitel) + await queryInterface.sequelize.query(` + ALTER TABLE community.vocab_course_lesson + ALTER COLUMN chapter_id DROP NOT NULL; + `); + + // Kurs-Wochen/Module hinzufügen + await queryInterface.sequelize.query(` + ALTER TABLE community.vocab_course_lesson + ADD COLUMN IF NOT EXISTS week_number INTEGER, + ADD COLUMN IF NOT EXISTS day_number INTEGER, + ADD COLUMN IF NOT EXISTS lesson_type TEXT DEFAULT 'vocab', + ADD COLUMN IF NOT EXISTS audio_url TEXT, + ADD COLUMN IF NOT EXISTS cultural_notes TEXT; + `); + + // Indizes für Wochen/Tage + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx + ON community.vocab_course_lesson(course_id, week_number); + CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx + ON community.vocab_course_lesson(lesson_type); + `); + + // Kommentar hinzufügen für lesson_type + await queryInterface.sequelize.query(` + COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS + 'Type: vocab, grammar, conversation, culture, review'; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE community.vocab_course_lesson + DROP COLUMN IF EXISTS week_number, + DROP COLUMN IF EXISTS day_number, + DROP COLUMN IF EXISTS lesson_type, + DROP COLUMN IF EXISTS audio_url, + DROP COLUMN IF EXISTS cultural_notes; + `); + } +}; diff --git a/backend/migrations/20260115000003-add-course-learning-goals.cjs b/backend/migrations/20260115000003-add-course-learning-goals.cjs new file mode 100644 index 0000000..23bfa2f --- /dev/null +++ b/backend/migrations/20260115000003-add-course-learning-goals.cjs @@ -0,0 +1,33 @@ +/* eslint-disable */ +'use strict'; + +module.exports = { + async up(queryInterface) { + // Lernziele für Lektionen + await queryInterface.sequelize.query(` + ALTER TABLE community.vocab_course_lesson + ADD COLUMN IF NOT EXISTS target_minutes INTEGER, + ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80, + ADD COLUMN IF NOT EXISTS requires_review BOOLEAN DEFAULT false; + `); + + // Kommentare hinzufügen + await queryInterface.sequelize.query(` + COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS + 'Zielzeit in Minuten für diese Lektion'; + COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS + 'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)'; + COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS + 'Muss diese Lektion wiederholt werden, wenn Ziel nicht erreicht?'; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE community.vocab_course_lesson + DROP COLUMN IF EXISTS target_minutes, + DROP COLUMN IF EXISTS target_score_percent, + DROP COLUMN IF EXISTS requires_review; + `); + } +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index a98af91..d6351ad 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -104,6 +104,13 @@ import Weather from './falukant/data/weather.js'; import ProductWeatherEffect from './falukant/type/product_weather_effect.js'; import Blog from './community/blog.js'; import BlogPost from './community/blog_post.js'; +import VocabCourse from './community/vocab_course.js'; +import VocabCourseLesson from './community/vocab_course_lesson.js'; +import VocabCourseEnrollment from './community/vocab_course_enrollment.js'; +import VocabCourseProgress from './community/vocab_course_progress.js'; +import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js'; +import VocabGrammarExercise from './community/vocab_grammar_exercise.js'; +import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js'; import Campaign from './match3/campaign.js'; import Match3Level from './match3/level.js'; import Objective from './match3/objective.js'; @@ -941,5 +948,37 @@ export default function setupAssociations() { TaxiHighscore.belongsTo(TaxiMap, { foreignKey: 'mapId', as: 'map' }); TaxiMap.hasMany(TaxiHighscore, { foreignKey: 'mapId', as: 'highscores' }); + + // Vocab Course associations + VocabCourse.belongsTo(User, { foreignKey: 'ownerUserId', as: 'owner' }); + User.hasMany(VocabCourse, { foreignKey: 'ownerUserId', as: 'ownedCourses' }); + + VocabCourseLesson.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' }); + VocabCourse.hasMany(VocabCourseLesson, { foreignKey: 'courseId', as: 'lessons' }); + + VocabCourseEnrollment.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + User.hasMany(VocabCourseEnrollment, { foreignKey: 'userId', as: 'courseEnrollments' }); + VocabCourseEnrollment.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' }); + VocabCourse.hasMany(VocabCourseEnrollment, { foreignKey: 'courseId', as: 'enrollments' }); + + VocabCourseProgress.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + User.hasMany(VocabCourseProgress, { foreignKey: 'userId', as: 'courseProgress' }); + VocabCourseProgress.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' }); + VocabCourse.hasMany(VocabCourseProgress, { foreignKey: 'courseId', as: 'progress' }); + VocabCourseProgress.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' }); + VocabCourseLesson.hasMany(VocabCourseProgress, { foreignKey: 'lessonId', as: 'progress' }); + + // Grammar Exercise associations + VocabGrammarExercise.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' }); + VocabCourseLesson.hasMany(VocabGrammarExercise, { foreignKey: 'lessonId', as: 'grammarExercises' }); + VocabGrammarExercise.belongsTo(VocabGrammarExerciseType, { foreignKey: 'exerciseTypeId', as: 'exerciseType' }); + VocabGrammarExerciseType.hasMany(VocabGrammarExercise, { foreignKey: 'exerciseTypeId', as: 'exercises' }); + VocabGrammarExercise.belongsTo(User, { foreignKey: 'createdByUserId', as: 'creator' }); + User.hasMany(VocabGrammarExercise, { foreignKey: 'createdByUserId', as: 'createdGrammarExercises' }); + + VocabGrammarExerciseProgress.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + User.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'userId', as: 'grammarExerciseProgress' }); + VocabGrammarExerciseProgress.belongsTo(VocabGrammarExercise, { foreignKey: 'exerciseId', as: 'exercise' }); + VocabGrammarExercise.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'exerciseId', as: 'progress' }); } diff --git a/backend/models/community/vocab_course.js b/backend/models/community/vocab_course.js new file mode 100644 index 0000000..f3c3543 --- /dev/null +++ b/backend/models/community/vocab_course.js @@ -0,0 +1,69 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class VocabCourse extends Model {} + +VocabCourse.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + ownerUserId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'owner_user_id' + }, + title: { + type: DataTypes.TEXT, + allowNull: false + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + languageId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'language_id' + }, + difficultyLevel: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + field: 'difficulty_level' + }, + isPublic: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'is_public' + }, + shareCode: { + type: DataTypes.TEXT, + allowNull: true, + unique: true, + field: 'share_code' + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } +}, { + sequelize, + modelName: 'VocabCourse', + tableName: 'vocab_course', + schema: 'community', + timestamps: true, + underscored: true +}); + +export default VocabCourse; diff --git a/backend/models/community/vocab_course_enrollment.js b/backend/models/community/vocab_course_enrollment.js new file mode 100644 index 0000000..adf2716 --- /dev/null +++ b/backend/models/community/vocab_course_enrollment.js @@ -0,0 +1,37 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class VocabCourseEnrollment extends Model {} + +VocabCourseEnrollment.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id' + }, + courseId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'course_id' + }, + enrolledAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'enrolled_at' + } +}, { + sequelize, + modelName: 'VocabCourseEnrollment', + tableName: 'vocab_course_enrollment', + schema: 'community', + timestamps: false, + underscored: true +}); + +export default VocabCourseEnrollment; diff --git a/backend/models/community/vocab_course_lesson.js b/backend/models/community/vocab_course_lesson.js new file mode 100644 index 0000000..bf8a755 --- /dev/null +++ b/backend/models/community/vocab_course_lesson.js @@ -0,0 +1,93 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class VocabCourseLesson extends Model {} + +VocabCourseLesson.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + courseId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'course_id' + }, + chapterId: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'chapter_id' + }, + lessonNumber: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'lesson_number' + }, + title: { + type: DataTypes.TEXT, + allowNull: false + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + weekNumber: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'week_number' + }, + dayNumber: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'day_number' + }, + lessonType: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'vocab', + field: 'lesson_type' + }, + audioUrl: { + type: DataTypes.TEXT, + allowNull: true, + field: 'audio_url' + }, + culturalNotes: { + type: DataTypes.TEXT, + allowNull: true, + field: 'cultural_notes' + }, + targetMinutes: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'target_minutes' + }, + targetScorePercent: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 80, + field: 'target_score_percent' + }, + requiresReview: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'requires_review' + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + } +}, { + sequelize, + modelName: 'VocabCourseLesson', + tableName: 'vocab_course_lesson', + schema: 'community', + timestamps: false, + underscored: true +}); + +export default VocabCourseLesson; diff --git a/backend/models/community/vocab_course_progress.js b/backend/models/community/vocab_course_progress.js new file mode 100644 index 0000000..9581f99 --- /dev/null +++ b/backend/models/community/vocab_course_progress.js @@ -0,0 +1,56 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class VocabCourseProgress extends Model {} + +VocabCourseProgress.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id' + }, + courseId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'course_id' + }, + lessonId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'lesson_id' + }, + completed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + score: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + lastAccessedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_accessed_at' + }, + completedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'completed_at' + } +}, { + sequelize, + modelName: 'VocabCourseProgress', + tableName: 'vocab_course_progress', + schema: 'community', + timestamps: false, + underscored: true +}); + +export default VocabCourseProgress; diff --git a/backend/models/community/vocab_grammar_exercise.js b/backend/models/community/vocab_grammar_exercise.js new file mode 100644 index 0000000..529ef2d --- /dev/null +++ b/backend/models/community/vocab_grammar_exercise.js @@ -0,0 +1,69 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class VocabGrammarExercise extends Model {} + +VocabGrammarExercise.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + lessonId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'lesson_id' + }, + exerciseTypeId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'exercise_type_id' + }, + exerciseNumber: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'exercise_number' + }, + title: { + type: DataTypes.TEXT, + allowNull: false + }, + instruction: { + type: DataTypes.TEXT, + allowNull: true + }, + questionData: { + type: DataTypes.JSONB, + allowNull: false, + field: 'question_data' + }, + answerData: { + type: DataTypes.JSONB, + allowNull: false, + field: 'answer_data' + }, + explanation: { + type: DataTypes.TEXT, + allowNull: true + }, + createdByUserId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'created_by_user_id' + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + } +}, { + sequelize, + modelName: 'VocabGrammarExercise', + tableName: 'vocab_grammar_exercise', + schema: 'community', + timestamps: false, + underscored: true +}); + +export default VocabGrammarExercise; diff --git a/backend/models/community/vocab_grammar_exercise_progress.js b/backend/models/community/vocab_grammar_exercise_progress.js new file mode 100644 index 0000000..4bf6bba --- /dev/null +++ b/backend/models/community/vocab_grammar_exercise_progress.js @@ -0,0 +1,57 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class VocabGrammarExerciseProgress extends Model {} + +VocabGrammarExerciseProgress.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id' + }, + exerciseId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'exercise_id' + }, + attempts: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + correctAttempts: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'correct_attempts' + }, + lastAttemptAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_attempt_at' + }, + completed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + completedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'completed_at' + } +}, { + sequelize, + modelName: 'VocabGrammarExerciseProgress', + tableName: 'vocab_grammar_exercise_progress', + schema: 'community', + timestamps: false, + underscored: true +}); + +export default VocabGrammarExerciseProgress; diff --git a/backend/models/community/vocab_grammar_exercise_type.js b/backend/models/community/vocab_grammar_exercise_type.js new file mode 100644 index 0000000..b55a145 --- /dev/null +++ b/backend/models/community/vocab_grammar_exercise_type.js @@ -0,0 +1,36 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class VocabGrammarExerciseType extends Model {} + +VocabGrammarExerciseType.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + } +}, { + sequelize, + modelName: 'VocabGrammarExerciseType', + tableName: 'vocab_grammar_exercise_type', + schema: 'community', + timestamps: false, + underscored: true +}); + +export default VocabGrammarExerciseType; diff --git a/backend/models/index.js b/backend/models/index.js index 1085842..65b5be4 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -129,6 +129,15 @@ import ChatRight from './chat/rights.js'; import ChatUserRight from './chat/user_rights.js'; import RoomType from './chat/room_type.js'; +// — Vocab Courses — +import VocabCourse from './community/vocab_course.js'; +import VocabCourseLesson from './community/vocab_course_lesson.js'; +import VocabCourseEnrollment from './community/vocab_course_enrollment.js'; +import VocabCourseProgress from './community/vocab_course_progress.js'; +import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js'; +import VocabGrammarExercise from './community/vocab_grammar_exercise.js'; +import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js'; + const models = { SettingsType, UserParamValue, @@ -263,6 +272,15 @@ const models = { TaxiMapTileStreet, TaxiMapTileHouse, TaxiHighscore, + + // Vocab Courses + VocabCourse, + VocabCourseLesson, + VocabCourseEnrollment, + VocabCourseProgress, + VocabGrammarExerciseType, + VocabGrammarExercise, + VocabGrammarExerciseProgress, }; export default models; diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index 2c31f01..fdfb290 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -22,6 +22,37 @@ router.get('/chapters/:chapterId', vocabController.getChapter); router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs); router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter); +// Courses +router.post('/courses', vocabController.createCourse); +router.get('/courses', vocabController.getCourses); +router.get('/courses/my', vocabController.getMyCourses); +router.get('/courses/:courseId', vocabController.getCourse); +router.put('/courses/:courseId', vocabController.updateCourse); +router.delete('/courses/:courseId', vocabController.deleteCourse); + +// Lessons +router.post('/courses/:courseId/lessons', vocabController.addLessonToCourse); +router.put('/lessons/:lessonId', vocabController.updateLesson); +router.delete('/lessons/:lessonId', vocabController.deleteLesson); + +// Enrollment +router.post('/courses/:courseId/enroll', vocabController.enrollInCourse); +router.delete('/courses/:courseId/enroll', vocabController.unenrollFromCourse); + +// Progress +router.get('/courses/:courseId/progress', vocabController.getCourseProgress); +router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress); + +// Grammar Exercises +router.get('/grammar/exercise-types', vocabController.getExerciseTypes); +router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise); +router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson); +router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress); +router.get('/grammar-exercises/:exerciseId', vocabController.getGrammarExercise); +router.post('/grammar-exercises/:exerciseId/check', vocabController.checkGrammarExerciseAnswer); +router.put('/grammar-exercises/:exerciseId', vocabController.updateGrammarExercise); +router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExercise); + export default router; diff --git a/backend/scripts/create-bisaya-course.js b/backend/scripts/create-bisaya-course.js new file mode 100755 index 0000000..f8f8636 --- /dev/null +++ b/backend/scripts/create-bisaya-course.js @@ -0,0 +1,309 @@ +#!/usr/bin/env node +/** + * Script zum Erstellen eines vollständigen 4-Wochen Bisaya-Kurses + * + * Verwendung: + * node backend/scripts/create-bisaya-course.js + */ + +import { sequelize } from '../utils/sequelize.js'; +import VocabCourse from '../models/community/vocab_course.js'; +import VocabCourseLesson from '../models/community/vocab_course_lesson.js'; +import User from '../models/community/user.js'; +import crypto from 'crypto'; + +const LESSONS = [ + // WOCHE 1: Grundlagen & Aussprache + { week: 1, day: 1, num: 1, type: 'conversation', title: 'Begrüßungen & Höflichkeit', + desc: 'Lerne die wichtigsten Begrüßungen und Höflichkeitsformeln', + targetMin: 15, targetScore: 80, review: false, + cultural: 'Philippiner schätzen Höflichkeit sehr. Lächeln ist wichtig!' }, + + { week: 1, day: 1, num: 2, type: 'vocab', title: 'Überlebenssätze - Teil 1', + desc: 'Die 10 wichtigsten Sätze für den Alltag', + targetMin: 20, targetScore: 85, review: true, + cultural: 'Diese Sätze helfen dir sofort im Alltag weiter.' }, + + { week: 1, day: 2, num: 3, type: 'vocab', title: 'Familienwörter', + desc: 'Mama, Papa, Kuya, Ate, Lola, Lolo und mehr', + targetMin: 20, targetScore: 85, review: true, + cultural: 'Kuya und Ate werden auch für Nicht-Verwandte verwendet – sehr respektvoll!' }, + + { week: 1, day: 2, num: 4, type: 'conversation', title: 'Familien-Gespräche', + desc: 'Einfache Gespräche mit Familienmitgliedern', + targetMin: 15, targetScore: 80, review: false, + cultural: 'Familienkonversationen sind herzlicher als formelle Gespräche.' }, + + { week: 1, day: 3, num: 5, type: 'conversation', title: 'Gefühle & Zuneigung', + desc: 'Mingaw ko nimo, Palangga taka und mehr', + targetMin: 15, targetScore: 80, review: false, + cultural: 'Palangga taka ist wärmer als "I love you" im Familienkontext.' }, + + { week: 1, day: 3, num: 6, type: 'vocab', title: 'Überlebenssätze - Teil 2', + desc: 'Weitere wichtige Alltagssätze', + targetMin: 20, targetScore: 85, review: true, + cultural: null }, + + { week: 1, day: 4, num: 7, type: 'conversation', title: 'Essen & Fürsorge', + desc: 'Nikaon ka? Kaon ta! Lami!', + targetMin: 15, targetScore: 80, review: false, + cultural: 'Essen = Liebe! "Nikaon na ka?" ist sehr fürsorglich.' }, + + { week: 1, day: 4, num: 8, type: 'vocab', title: 'Essen & Trinken', + desc: 'Wichtige Wörter rund ums Essen', + targetMin: 20, targetScore: 85, review: true, + cultural: null }, + + { week: 1, day: 5, num: 9, type: 'review', title: 'Woche 1 - Wiederholung', + desc: 'Wiederhole alle Inhalte der ersten Woche', + targetMin: 30, targetScore: 80, review: false, + cultural: 'Wiederholung ist der Schlüssel zum Erfolg!' }, + + { week: 1, day: 5, num: 10, type: 'vocab', title: 'Woche 1 - Vokabeltest', + desc: 'Teste dein Wissen aus Woche 1', + targetMin: 15, targetScore: 80, review: true, + cultural: null }, + + // WOCHE 2: Alltag & Familie + { week: 2, day: 1, num: 11, type: 'conversation', title: 'Alltagsgespräche - Teil 1', + desc: 'Wie war dein Tag? Was machst du?', + targetMin: 15, targetScore: 80, review: false, + cultural: 'Alltagsgespräche sind wichtig für echte Kommunikation.' }, + + { week: 2, day: 1, num: 12, type: 'vocab', title: 'Haus & Familie', + desc: 'Balay, Kwarto, Kusina, Pamilya', + targetMin: 20, targetScore: 85, review: true, + cultural: null }, + + { week: 2, day: 2, num: 13, type: 'conversation', title: 'Alltagsgespräche - Teil 2', + desc: 'Wohin gehst du? Was machst du heute?', + targetMin: 15, targetScore: 80, review: false, + cultural: null }, + + { week: 2, day: 2, num: 14, type: 'vocab', title: 'Ort & Richtung', + desc: 'Asa, dinhi, didto, padulong', + targetMin: 20, targetScore: 85, review: true, + cultural: null }, + + { week: 2, day: 3, num: 15, type: 'grammar', title: 'Zeitformen - Grundlagen', + desc: 'Ni-kaon ko, Mo-kaon ko - Vergangenheit und Zukunft', + targetMin: 25, targetScore: 75, review: true, + cultural: 'Cebuano hat keine komplexen Zeiten wie Deutsch. Zeit wird mit Präfixen ausgedrückt.' }, + + { week: 2, day: 3, num: 16, type: 'vocab', title: 'Zeit & Datum', + desc: 'Karon, ugma, gahapon, karon adlaw', + targetMin: 20, targetScore: 85, review: true, + cultural: null }, + + { week: 2, day: 4, num: 17, type: 'conversation', title: 'Einkaufen & Preise', + desc: 'Tagpila ni? Pwede barato?', + targetMin: 15, targetScore: 80, review: false, + cultural: 'Handeln ist in den Philippinen üblich und erwartet.' }, + + { week: 2, day: 4, num: 18, type: 'vocab', title: 'Zahlen & Preise', + desc: '1-100, Preise, Mengen', + targetMin: 25, targetScore: 85, review: true, + cultural: null }, + + { week: 2, day: 5, num: 19, type: 'review', title: 'Woche 2 - Wiederholung', + desc: 'Wiederhole alle Inhalte der zweiten Woche', + targetMin: 30, targetScore: 80, review: false, + cultural: null }, + + { week: 2, day: 5, num: 20, type: 'vocab', title: 'Woche 2 - Vokabeltest', + desc: 'Teste dein Wissen aus Woche 2', + targetMin: 15, targetScore: 80, review: true, + cultural: null }, + + // WOCHE 3: Vertiefung + { week: 3, day: 1, num: 21, type: 'conversation', title: 'Gefühle & Emotionen', + desc: 'Nalipay, nasubo, nahadlok, naguol', + targetMin: 15, targetScore: 80, review: false, + cultural: 'Emotionen auszudrücken ist wichtig für echte Verbindung.' }, + + { week: 3, day: 1, num: 22, type: 'vocab', title: 'Gefühle & Emotionen', + desc: 'Wörter für verschiedene Gefühle', + targetMin: 20, targetScore: 85, review: true, + cultural: null }, + + { week: 3, day: 2, num: 23, type: 'conversation', title: 'Gesundheit & Wohlbefinden', + desc: 'Sakit, maayo, tambal, doktor', + targetMin: 15, targetScore: 80, review: false, + cultural: null }, + + { week: 3, day: 2, num: 24, type: 'vocab', title: 'Körper & Gesundheit', + desc: 'Wörter rund um den Körper und Gesundheit', + targetMin: 20, targetScore: 85, review: true, + cultural: null }, + + { week: 3, day: 3, num: 25, type: 'grammar', title: 'Höflichkeitsformen', + desc: 'Palihug, Pwede, Tabang', + targetMin: 20, targetScore: 75, review: true, + cultural: 'Höflichkeit ist extrem wichtig in der philippinischen Kultur.' }, + + { week: 3, day: 3, num: 26, type: 'conversation', title: 'Bitten & Fragen', + desc: 'Wie man höflich fragt und bittet', + targetMin: 15, targetScore: 80, review: false, + cultural: null }, + + { week: 3, day: 4, num: 27, type: 'conversation', title: 'Kinder & Familie', + desc: 'Gespräche mit und über Kinder', + targetMin: 15, targetScore: 80, review: false, + cultural: 'Kinder sind sehr wichtig in philippinischen Familien.' }, + + { week: 3, day: 4, num: 28, type: 'vocab', title: 'Kinder & Spiel', + desc: 'Wörter für Kinder und Spielsachen', + targetMin: 20, targetScore: 85, review: true, + cultural: null }, + + { week: 3, day: 5, num: 29, type: 'review', title: 'Woche 3 - Wiederholung', + desc: 'Wiederhole alle Inhalte der dritten Woche', + targetMin: 30, targetScore: 80, review: false, + cultural: null }, + + { week: 3, day: 5, num: 30, type: 'vocab', title: 'Woche 3 - Vokabeltest', + desc: 'Teste dein Wissen aus Woche 3', + targetMin: 15, targetScore: 80, review: true, + cultural: null }, + + // WOCHE 4: Freies Sprechen + { week: 4, day: 1, num: 31, type: 'conversation', title: 'Freies Gespräch - Thema 1', + desc: 'Übe freies Sprechen zu verschiedenen Themen', + targetMin: 20, targetScore: 75, review: false, + cultural: 'Fehler sind okay! Philippiner schätzen das Bemühen.' }, + + { week: 4, day: 1, num: 32, type: 'vocab', title: 'Wiederholung - Woche 1 & 2', + desc: 'Wiederhole wichtige Vokabeln aus den ersten beiden Wochen', + targetMin: 25, targetScore: 85, review: true, + cultural: null }, + + { week: 4, day: 2, num: 33, type: 'conversation', title: 'Freies Gespräch - Thema 2', + desc: 'Weitere Übung im freien Sprechen', + targetMin: 20, targetScore: 75, review: false, + cultural: null }, + + { week: 4, day: 2, num: 34, type: 'vocab', title: 'Wiederholung - Woche 3', + desc: 'Wiederhole wichtige Vokabeln aus Woche 3', + targetMin: 25, targetScore: 85, review: true, + cultural: null }, + + { week: 4, day: 3, num: 35, type: 'conversation', title: 'Komplexere Gespräche', + desc: 'Längere Gespräche zu verschiedenen Themen', + targetMin: 25, targetScore: 75, review: false, + cultural: 'Je mehr du sprichst, desto besser wirst du!' }, + + { week: 4, day: 3, num: 36, type: 'review', title: 'Gesamtwiederholung', + desc: 'Wiederhole alle wichtigen Inhalte des Kurses', + targetMin: 30, targetScore: 80, review: false, + cultural: null }, + + { week: 4, day: 4, num: 37, type: 'conversation', title: 'Praktische Übung', + desc: 'Simuliere echte Gesprächssituationen', + targetMin: 25, targetScore: 75, review: false, + cultural: null }, + + { week: 4, day: 4, num: 38, type: 'vocab', title: 'Abschlusstest - Vokabeln', + desc: 'Finaler Vokabeltest über den gesamten Kurs', + targetMin: 20, targetScore: 80, review: true, + cultural: null }, + + { week: 4, day: 5, num: 39, type: 'review', title: 'Abschlussprüfung', + desc: 'Finale Prüfung über alle Kursinhalte', + targetMin: 30, targetScore: 80, review: false, + cultural: 'Gratulation zum Abschluss des Kurses!' }, + + { week: 4, day: 5, num: 40, type: 'culture', title: 'Kulturelle Tipps & Tricks', + desc: 'Wichtige kulturelle Hinweise für den Alltag', + targetMin: 15, targetScore: 0, review: false, + cultural: 'Kulturelles Verständnis ist genauso wichtig wie die Sprache selbst.' } +]; + +async function createBisayaCourse(languageId, ownerHashedId) { + try { + // Finde User + const user = await User.findOne({ where: { hashedId: ownerHashedId } }); + if (!user) { + throw new Error(`User mit hashedId ${ownerHashedId} nicht gefunden`); + } + + // Prüfe, ob Sprache existiert + const [lang] = await sequelize.query( + `SELECT id FROM community.vocab_language WHERE id = :langId`, + { replacements: { langId: languageId }, type: sequelize.QueryTypes.SELECT } + ); + if (!lang) { + throw new Error(`Sprache mit ID ${languageId} nicht gefunden`); + } + + // Erstelle Kurs + const shareCode = crypto.randomBytes(8).toString('hex'); + const course = await VocabCourse.create({ + ownerUserId: user.id, + title: 'Bisaya für Familien - Schnellstart in 4 Wochen', + description: 'Lerne Bisaya (Cebuano) schnell und praktisch für den Familienalltag. Fokus auf Sprechen & Hören mit strukturiertem 4-Wochen-Plan.', + languageId: Number(languageId), + difficultyLevel: 1, + isPublic: true, + shareCode + }); + + console.log(`✅ Kurs erstellt: ${course.id} - "${course.title}"`); + console.log(` Share-Code: ${shareCode}`); + + // Erstelle Lektionen + for (const lessonData of LESSONS) { + const lesson = await VocabCourseLesson.create({ + courseId: course.id, + chapterId: null, // Wird später mit Vokabeln verknüpft + lessonNumber: lessonData.num, + title: lessonData.title, + description: lessonData.desc, + weekNumber: lessonData.week, + dayNumber: lessonData.day, + lessonType: lessonData.type, + culturalNotes: lessonData.cultural, + targetMinutes: lessonData.targetMin, + targetScorePercent: lessonData.targetScore, + requiresReview: lessonData.review + }); + console.log(` ✅ Lektion ${lessonData.num}: ${lessonData.title} (Woche ${lessonData.week}, Tag ${lessonData.day})`); + } + + console.log(`\n🎉 Kurs erfolgreich erstellt mit ${LESSONS.length} Lektionen!`); + console.log(`\n📊 Kurs-Statistik:`); + console.log(` - Gesamte Lektionen: ${LESSONS.length}`); + console.log(` - Vokabel-Lektionen: ${LESSONS.filter(l => l.type === 'vocab').length}`); + console.log(` - Konversations-Lektionen: ${LESSONS.filter(l => l.type === 'conversation').length}`); + console.log(` - Grammatik-Lektionen: ${LESSONS.filter(l => l.type === 'grammar').length}`); + console.log(` - Wiederholungs-Lektionen: ${LESSONS.filter(l => l.type === 'review').length}`); + console.log(` - Durchschnittliche Zeit pro Tag: ~${Math.round(LESSONS.reduce((sum, l) => sum + l.targetMin, 0) / (4 * 5))} Minuten`); + console.log(`\n💡 Nächste Schritte:`); + console.log(` 1. Füge Vokabeln zu den Vokabel-Lektionen hinzu`); + console.log(` 2. Erstelle Grammatik-Übungen für die Grammatik-Lektionen`); + console.log(` 3. Teile den Kurs mit anderen (Share-Code: ${shareCode})`); + + return course; + } catch (error) { + console.error('❌ Fehler beim Erstellen des Kurses:', error); + throw error; + } +} + +// CLI-Aufruf +const languageId = process.argv[2]; +const ownerHashedId = process.argv[3]; + +if (!languageId || !ownerHashedId) { + console.error('Verwendung: node create-bisaya-course.js '); + console.error('Beispiel: node create-bisaya-course.js 1 abc123def456'); + process.exit(1); +} + +createBisayaCourse(languageId, ownerHashedId) + .then(() => { + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index e9d5229..2c5f7a7 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -1,7 +1,15 @@ import crypto from 'crypto'; import User from '../models/community/user.js'; +import VocabCourse from '../models/community/vocab_course.js'; +import VocabCourseLesson from '../models/community/vocab_course_lesson.js'; +import VocabCourseEnrollment from '../models/community/vocab_course_enrollment.js'; +import VocabCourseProgress from '../models/community/vocab_course_progress.js'; +import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js'; +import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js'; +import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js'; import { sequelize } from '../utils/sequelize.js'; import { notifyUser } from '../utils/socket.js'; +import { Op } from 'sequelize'; export default class VocabService { async _getUserByHashedId(hashedUserId) { @@ -527,6 +535,679 @@ export default class VocabService { return { created: Boolean(mapping?.id) }; }); } + + // ========== COURSE METHODS ========== + + async createCourse(hashedUserId, { title, description, languageId, difficultyLevel = 1, isPublic = false }) { + const user = await this._getUserByHashedId(hashedUserId); + + // Prüfe Zugriff auf Sprache + await this._getLanguageAccess(user.id, languageId); + + const shareCode = isPublic ? crypto.randomBytes(8).toString('hex') : null; + + const course = await VocabCourse.create({ + ownerUserId: user.id, + title, + description, + languageId: Number(languageId), + difficultyLevel: Number(difficultyLevel) || 1, + isPublic: Boolean(isPublic), + shareCode + }); + + return course.get({ plain: true }); + } + + async getCourses(hashedUserId, { includePublic = true, includeOwn = true } = {}) { + const user = await this._getUserByHashedId(hashedUserId); + + const where = {}; + if (includeOwn && includePublic) { + where[Op.or] = [ + { ownerUserId: user.id }, + { isPublic: true } + ]; + } else if (includeOwn) { + where.ownerUserId = user.id; + } else if (includePublic) { + where.isPublic = true; + } + + const courses = await VocabCourse.findAll({ + where, + order: [['createdAt', 'DESC']] + }); + + return courses.map(c => c.get({ plain: true })); + } + + async getCourse(hashedUserId, courseId) { + const user = await this._getUserByHashedId(hashedUserId); + const course = await VocabCourse.findByPk(courseId, { + include: [ + { + model: VocabCourseLesson, + as: 'lessons', + order: [['lessonNumber', 'ASC']] + } + ] + }); + + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + + // Prüfe Zugriff + if (course.ownerUserId !== user.id && !course.isPublic) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + + const courseData = course.get({ plain: true }); + courseData.lessons = courseData.lessons || []; + + // Sortiere Lektionen nach Woche, Tag, dann Nummer + courseData.lessons.sort((a, b) => { + if (a.weekNumber !== b.weekNumber) { + return (a.weekNumber || 999) - (b.weekNumber || 999); + } + if (a.dayNumber !== b.dayNumber) { + return (a.dayNumber || 999) - (b.dayNumber || 999); + } + return a.lessonNumber - b.lessonNumber; + }); + + return courseData; + } + + async updateCourse(hashedUserId, courseId, { title, description, difficultyLevel, isPublic }) { + const user = await this._getUserByHashedId(hashedUserId); + const course = await VocabCourse.findByPk(courseId); + + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + + if (course.ownerUserId !== user.id) { + const err = new Error('Only the owner can update the course'); + err.status = 403; + throw err; + } + + const updates = {}; + if (title !== undefined) updates.title = title; + if (description !== undefined) updates.description = description; + if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel); + if (isPublic !== undefined) { + updates.isPublic = Boolean(isPublic); + // Generiere Share-Code wenn Kurs öffentlich wird + if (isPublic && !course.shareCode) { + updates.shareCode = crypto.randomBytes(8).toString('hex'); + } else if (!isPublic) { + updates.shareCode = null; + } + } + + await course.update(updates); + return course.get({ plain: true }); + } + + async deleteCourse(hashedUserId, courseId) { + const user = await this._getUserByHashedId(hashedUserId); + const course = await VocabCourse.findByPk(courseId); + + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + + if (course.ownerUserId !== user.id) { + const err = new Error('Only the owner can delete the course'); + err.status = 403; + throw err; + } + + await course.destroy(); + return { success: true }; + } + + async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) { + const user = await this._getUserByHashedId(hashedUserId); + const course = await VocabCourse.findByPk(courseId); + + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + + if (course.ownerUserId !== user.id) { + const err = new Error('Only the owner can add lessons'); + err.status = 403; + throw err; + } + + // Prüfe, ob Kapitel zur gleichen Sprache gehört (nur wenn chapterId angegeben) + if (chapterId) { + const [chapter] = await sequelize.query( + `SELECT language_id FROM community.vocab_chapter WHERE id = :chapterId`, + { + replacements: { chapterId: Number(chapterId) }, + type: sequelize.QueryTypes.SELECT + } + ); + + if (!chapter || chapter.language_id !== course.languageId) { + const err = new Error('Chapter does not belong to the course language'); + err.status = 400; + throw err; + } + } + + const lesson = await VocabCourseLesson.create({ + courseId: course.id, + chapterId: chapterId ? Number(chapterId) : null, + lessonNumber: Number(lessonNumber), + title, + description, + weekNumber: weekNumber ? Number(weekNumber) : null, + dayNumber: dayNumber ? Number(dayNumber) : null, + lessonType: lessonType || 'vocab', + audioUrl: audioUrl || null, + culturalNotes: culturalNotes || null, + targetMinutes: targetMinutes ? Number(targetMinutes) : null, + targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80, + requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false + }); + + return lesson.get({ plain: true }); + } + + async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) { + const user = await this._getUserByHashedId(hashedUserId); + const lesson = await VocabCourseLesson.findByPk(lessonId, { + include: [{ model: VocabCourse, as: 'course' }] + }); + + if (!lesson) { + const err = new Error('Lesson not found'); + err.status = 404; + throw err; + } + + if (lesson.course.ownerUserId !== user.id) { + const err = new Error('Only the owner can update lessons'); + err.status = 403; + throw err; + } + + const updates = {}; + if (title !== undefined) updates.title = title; + if (description !== undefined) updates.description = description; + if (lessonNumber !== undefined) updates.lessonNumber = Number(lessonNumber); + if (weekNumber !== undefined) updates.weekNumber = weekNumber ? Number(weekNumber) : null; + if (dayNumber !== undefined) updates.dayNumber = dayNumber ? Number(dayNumber) : null; + if (lessonType !== undefined) updates.lessonType = lessonType; + if (audioUrl !== undefined) updates.audioUrl = audioUrl; + if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes; + if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null; + if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent); + if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview); + + await lesson.update(updates); + return lesson.get({ plain: true }); + } + + async deleteLesson(hashedUserId, lessonId) { + const user = await this._getUserByHashedId(hashedUserId); + const lesson = await VocabCourseLesson.findByPk(lessonId, { + include: [{ model: VocabCourse, as: 'course' }] + }); + + if (!lesson) { + const err = new Error('Lesson not found'); + err.status = 404; + throw err; + } + + if (lesson.course.ownerUserId !== user.id) { + const err = new Error('Only the owner can delete lessons'); + err.status = 403; + throw err; + } + + await lesson.destroy(); + return { success: true }; + } + + async enrollInCourse(hashedUserId, courseId) { + const user = await this._getUserByHashedId(hashedUserId); + const course = await VocabCourse.findByPk(courseId); + + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + + // Prüfe Zugriff + if (course.ownerUserId !== user.id && !course.isPublic) { + const err = new Error('Course is not public'); + err.status = 403; + throw err; + } + + const [enrollment, created] = await VocabCourseEnrollment.findOrCreate({ + where: { userId: user.id, courseId: course.id }, + defaults: { userId: user.id, courseId: course.id } + }); + + if (!created) { + const err = new Error('Already enrolled in this course'); + err.status = 400; + throw err; + } + + return enrollment.get({ plain: true }); + } + + async unenrollFromCourse(hashedUserId, courseId) { + const user = await this._getUserByHashedId(hashedUserId); + const enrollment = await VocabCourseEnrollment.findOne({ + where: { userId: user.id, courseId: Number(courseId) } + }); + + if (!enrollment) { + const err = new Error('Not enrolled in this course'); + err.status = 404; + throw err; + } + + await enrollment.destroy(); + return { success: true }; + } + + async getMyCourses(hashedUserId) { + const user = await this._getUserByHashedId(hashedUserId); + + const enrollments = await VocabCourseEnrollment.findAll({ + where: { userId: user.id }, + include: [{ model: VocabCourse, as: 'course' }], + order: [['enrolledAt', 'DESC']] + }); + + return enrollments.map(e => ({ + ...e.course.get({ plain: true }), + enrolledAt: e.enrolledAt + })); + } + + async getCourseProgress(hashedUserId, courseId) { + const user = await this._getUserByHashedId(hashedUserId); + + // Prüfe Einschreibung + const enrollment = await VocabCourseEnrollment.findOne({ + where: { userId: user.id, courseId: Number(courseId) } + }); + + if (!enrollment) { + const err = new Error('Not enrolled in this course'); + err.status = 403; + throw err; + } + + const progress = await VocabCourseProgress.findAll({ + where: { userId: user.id, courseId: Number(courseId) }, + include: [{ model: VocabCourseLesson, as: 'lesson' }], + order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']] + }); + + return progress.map(p => p.get({ plain: true })); + } + + async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes }) { + const user = await this._getUserByHashedId(hashedUserId); + const lesson = await VocabCourseLesson.findByPk(lessonId, { + include: [{ model: VocabCourse, as: 'course' }] + }); + + if (!lesson) { + const err = new Error('Lesson not found'); + err.status = 404; + throw err; + } + + // Prüfe Einschreibung + const enrollment = await VocabCourseEnrollment.findOne({ + where: { userId: user.id, courseId: lesson.courseId } + }); + + if (!enrollment) { + const err = new Error('Not enrolled in this course'); + err.status = 403; + throw err; + } + + const lessonData = await VocabCourseLesson.findByPk(lesson.id); + const targetScore = lessonData.targetScorePercent || 80; + const actualScore = Number(score) || 0; + const hasReachedTarget = actualScore >= targetScore; + + // Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true) + const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false); + + const [progress, created] = await VocabCourseProgress.findOrCreate({ + where: { userId: user.id, lessonId: lesson.id }, + defaults: { + userId: user.id, + courseId: lesson.courseId, + lessonId: lesson.id, + completed: isCompleted, + score: actualScore, + lastAccessedAt: new Date() + } + }); + + if (!created) { + const updates = { lastAccessedAt: new Date() }; + if (score !== undefined) { + updates.score = Math.max(progress.score, actualScore); + // Prüfe, ob Ziel jetzt erreicht wurde + if (updates.score >= targetScore && !progress.completed) { + if (!lessonData.requiresReview) { + updates.completed = true; + updates.completedAt = new Date(); + } + } + } + if (completed !== undefined) { + updates.completed = Boolean(completed); + if (completed && !progress.completedAt) { + updates.completedAt = new Date(); + } + } + await progress.update(updates); + } else if (isCompleted) { + progress.completed = true; + progress.completedAt = new Date(); + await progress.save(); + } + + const progressData = progress.get({ plain: true }); + progressData.targetScore = targetScore; + progressData.hasReachedTarget = progressData.score >= targetScore; + progressData.needsReview = lessonData.requiresReview && !progressData.hasReachedTarget; + + return progressData; + } + + // ========== GRAMMAR EXERCISE METHODS ========== + + async getExerciseTypes() { + const types = await VocabGrammarExerciseType.findAll({ + order: [['name', 'ASC']] + }); + return types.map(t => t.get({ plain: true })); + } + + async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) { + const user = await this._getUserByHashedId(hashedUserId); + const lesson = await VocabCourseLesson.findByPk(lessonId, { + include: [{ model: VocabCourse, as: 'course' }] + }); + + if (!lesson) { + const err = new Error('Lesson not found'); + err.status = 404; + throw err; + } + + // Prüfe, ob User Besitzer des Kurses ist + if (lesson.course.ownerUserId !== user.id) { + const err = new Error('Only the owner can add grammar exercises'); + err.status = 403; + throw err; + } + + const exercise = await VocabGrammarExercise.create({ + lessonId: lesson.id, + exerciseTypeId: Number(exerciseTypeId), + exerciseNumber: Number(exerciseNumber), + title, + instruction, + questionData, + answerData, + explanation, + createdByUserId: user.id + }); + + return exercise.get({ plain: true }); + } + + async getGrammarExercisesForLesson(hashedUserId, lessonId) { + const user = await this._getUserByHashedId(hashedUserId); + const lesson = await VocabCourseLesson.findByPk(lessonId, { + include: [{ model: VocabCourse, as: 'course' }] + }); + + if (!lesson) { + const err = new Error('Lesson not found'); + err.status = 404; + throw err; + } + + // Prüfe Zugriff + if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + + const exercises = await VocabGrammarExercise.findAll({ + where: { lessonId: lesson.id }, + include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }], + order: [['exerciseNumber', 'ASC']] + }); + + return exercises.map(e => e.get({ plain: true })); + } + + async getGrammarExercise(hashedUserId, exerciseId) { + const user = await this._getUserByHashedId(hashedUserId); + const exercise = await VocabGrammarExercise.findByPk(exerciseId, { + include: [ + { model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }, + { model: VocabGrammarExerciseType, as: 'exerciseType' } + ] + }); + + if (!exercise) { + const err = new Error('Exercise not found'); + err.status = 404; + throw err; + } + + // Prüfe Zugriff + if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + + return exercise.get({ plain: true }); + } + + async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) { + const user = await this._getUserByHashedId(hashedUserId); + const exercise = await VocabGrammarExercise.findByPk(exerciseId, { + include: [ + { model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] } + ] + }); + + if (!exercise) { + const err = new Error('Exercise not found'); + err.status = 404; + throw err; + } + + // Prüfe Einschreibung + const enrollment = await VocabCourseEnrollment.findOne({ + where: { userId: user.id, courseId: exercise.lesson.courseId } + }); + + if (!enrollment) { + const err = new Error('Not enrolled in this course'); + err.status = 403; + throw err; + } + + // Überprüfe Antwort (vereinfachte Logik - kann je nach Übungstyp erweitert werden) + const isCorrect = this._checkAnswer(exercise.answerData, userAnswer, exercise.exerciseTypeId); + + // Speichere Fortschritt + const [progress, created] = await VocabGrammarExerciseProgress.findOrCreate({ + where: { userId: user.id, exerciseId: exercise.id }, + defaults: { + userId: user.id, + exerciseId: exercise.id, + attempts: 1, + correctAttempts: isCorrect ? 1 : 0, + lastAttemptAt: new Date(), + completed: false + } + }); + + if (!created) { + progress.attempts += 1; + if (isCorrect) { + progress.correctAttempts += 1; + if (!progress.completed) { + progress.completed = true; + progress.completedAt = new Date(); + } + } + progress.lastAttemptAt = new Date(); + await progress.save(); + } else if (isCorrect) { + progress.completed = true; + progress.completedAt = new Date(); + await progress.save(); + } + + return { + correct: isCorrect, + explanation: exercise.explanation, + progress: progress.get({ plain: true }) + }; + } + + _checkAnswer(answerData, userAnswer, exerciseTypeId) { + // Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden + if (!answerData || !userAnswer) return false; + + // Für Multiple Choice: Prüfe ob userAnswer eine der richtigen Antworten ist + if (exerciseTypeId === 2) { // multiple_choice + const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct]; + return correctAnswers.includes(userAnswer); + } + + // Für Lückentext: Normalisiere und vergleiche + if (exerciseTypeId === 1) { // gap_fill + const normalize = (str) => String(str || '').trim().toLowerCase(); + const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct]; + const normalizedUserAnswer = normalize(userAnswer); + return correctAnswers.some(correct => normalize(correct) === normalizedUserAnswer); + } + + // Für andere Typen: einfacher String-Vergleich (kann später erweitert werden) + const normalize = (str) => String(str || '').trim().toLowerCase(); + const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct]; + return correctAnswers.some(correct => normalize(correct) === normalize(userAnswer)); + } + + async getGrammarExerciseProgress(hashedUserId, lessonId) { + const user = await this._getUserByHashedId(hashedUserId); + const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId); + + const exerciseIds = exercises.map(e => e.id); + const progress = await VocabGrammarExerciseProgress.findAll({ + where: { + userId: user.id, + exerciseId: { [Op.in]: exerciseIds } + } + }); + + const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })])); + + return exercises.map(exercise => ({ + ...exercise, + progress: progressMap.get(exercise.id) || null + })); + } + + async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) { + const user = await this._getUserByHashedId(hashedUserId); + const exercise = await VocabGrammarExercise.findByPk(exerciseId, { + include: [ + { model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] } + ] + }); + + if (!exercise) { + const err = new Error('Exercise not found'); + err.status = 404; + throw err; + } + + if (exercise.lesson.course.ownerUserId !== user.id) { + const err = new Error('Only the owner can update exercises'); + err.status = 403; + throw err; + } + + const updates = {}; + if (title !== undefined) updates.title = title; + if (instruction !== undefined) updates.instruction = instruction; + if (questionData !== undefined) updates.questionData = questionData; + if (answerData !== undefined) updates.answerData = answerData; + if (explanation !== undefined) updates.explanation = explanation; + if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber); + + await exercise.update(updates); + return exercise.get({ plain: true }); + } + + async deleteGrammarExercise(hashedUserId, exerciseId) { + const user = await this._getUserByHashedId(hashedUserId); + const exercise = await VocabGrammarExercise.findByPk(exerciseId, { + include: [ + { model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] } + ] + }); + + if (!exercise) { + const err = new Error('Exercise not found'); + err.status = 404; + throw err; + } + + if (exercise.lesson.course.ownerUserId !== user.id) { + const err = new Error('Only the owner can delete exercises'); + err.status = 403; + throw err; + } + + await exercise.destroy(); + return { success: true }; + } } diff --git a/backend/sql/create-vocab-courses.sql b/backend/sql/create-vocab-courses.sql new file mode 100644 index 0000000..996e4cf --- /dev/null +++ b/backend/sql/create-vocab-courses.sql @@ -0,0 +1,242 @@ +-- ============================================ +-- Vocab Courses - Vollständige SQL-Installation +-- ============================================ +-- Führe diese Queries direkt auf dem Server aus +-- Reihenfolge beachten! + +-- ============================================ +-- 1. Kurs-Tabellen erstellen +-- ============================================ + +-- Kurs-Tabelle +CREATE TABLE IF NOT EXISTS community.vocab_course ( + id SERIAL PRIMARY KEY, + owner_user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + language_id INTEGER NOT NULL, + difficulty_level INTEGER DEFAULT 1, + is_public BOOLEAN DEFAULT false, + share_code TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_owner_fk + FOREIGN KEY (owner_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_share_code_uniq UNIQUE (share_code) +); + +-- Lektionen innerhalb eines Kurses +CREATE TABLE IF NOT EXISTS community.vocab_course_lesson ( + id SERIAL PRIMARY KEY, + course_id INTEGER NOT NULL, + chapter_id INTEGER, + lesson_number INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + week_number INTEGER, + day_number INTEGER, + lesson_type TEXT DEFAULT 'vocab', + audio_url TEXT, + cultural_notes TEXT, + target_minutes INTEGER, + target_score_percent INTEGER DEFAULT 80, + requires_review BOOLEAN DEFAULT false, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_lesson_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_lesson_chapter_fk + FOREIGN KEY (chapter_id) + REFERENCES community.vocab_chapter(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_lesson_unique UNIQUE (course_id, lesson_number) +); + +-- Einschreibungen in Kurse +CREATE TABLE IF NOT EXISTS community.vocab_course_enrollment ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + course_id INTEGER NOT NULL, + enrolled_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_enrollment_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_enrollment_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_enrollment_unique UNIQUE (user_id, course_id) +); + +-- Fortschritt pro User und Lektion +CREATE TABLE IF NOT EXISTS community.vocab_course_progress ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + course_id INTEGER NOT NULL, + lesson_id INTEGER NOT NULL, + completed BOOLEAN DEFAULT false, + score INTEGER DEFAULT 0, + last_accessed_at TIMESTAMP WITHOUT TIME ZONE, + completed_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT vocab_course_progress_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_lesson_fk + FOREIGN KEY (lesson_id) + REFERENCES community.vocab_course_lesson(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_unique UNIQUE (user_id, lesson_id) +); + +-- ============================================ +-- 2. Grammatik-Übungstabellen erstellen +-- ============================================ + +-- Grammatik-Übungstypen +CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Grammatik-Übungen +CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise ( + id SERIAL PRIMARY KEY, + lesson_id INTEGER NOT NULL, + exercise_type_id INTEGER NOT NULL, + exercise_number INTEGER NOT NULL, + title TEXT NOT NULL, + instruction TEXT, + question_data JSONB NOT NULL, + answer_data JSONB NOT NULL, + explanation TEXT, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_grammar_exercise_lesson_fk + FOREIGN KEY (lesson_id) + REFERENCES community.vocab_course_lesson(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_type_fk + FOREIGN KEY (exercise_type_id) + REFERENCES community.vocab_grammar_exercise_type(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number) +); + +-- Fortschritt für Grammatik-Übungen +CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + exercise_id INTEGER NOT NULL, + attempts INTEGER DEFAULT 0, + correct_attempts INTEGER DEFAULT 0, + last_attempt_at TIMESTAMP WITHOUT TIME ZONE, + completed BOOLEAN DEFAULT false, + completed_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT vocab_grammar_exercise_progress_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_progress_exercise_fk + FOREIGN KEY (exercise_id) + REFERENCES community.vocab_grammar_exercise(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id) +); + +-- ============================================ +-- 3. Indizes erstellen +-- ============================================ + +-- Kurs-Indizes +CREATE INDEX IF NOT EXISTS vocab_course_owner_idx + ON community.vocab_course(owner_user_id); +CREATE INDEX IF NOT EXISTS vocab_course_language_idx + ON community.vocab_course(language_id); +CREATE INDEX IF NOT EXISTS vocab_course_public_idx + ON community.vocab_course(is_public); + +-- Lektion-Indizes +CREATE INDEX IF NOT EXISTS vocab_course_lesson_course_idx + ON community.vocab_course_lesson(course_id); +CREATE INDEX IF NOT EXISTS vocab_course_lesson_chapter_idx + ON community.vocab_course_lesson(chapter_id); +CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx + ON community.vocab_course_lesson(course_id, week_number); +CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx + ON community.vocab_course_lesson(lesson_type); + +-- Einschreibungs-Indizes +CREATE INDEX IF NOT EXISTS vocab_course_enrollment_user_idx + ON community.vocab_course_enrollment(user_id); +CREATE INDEX IF NOT EXISTS vocab_course_enrollment_course_idx + ON community.vocab_course_enrollment(course_id); + +-- Fortschritts-Indizes +CREATE INDEX IF NOT EXISTS vocab_course_progress_user_idx + ON community.vocab_course_progress(user_id); +CREATE INDEX IF NOT EXISTS vocab_course_progress_course_idx + ON community.vocab_course_progress(course_id); +CREATE INDEX IF NOT EXISTS vocab_course_progress_lesson_idx + ON community.vocab_course_progress(lesson_id); + +-- Grammatik-Übungs-Indizes +CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx + ON community.vocab_grammar_exercise(lesson_id); +CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx + ON community.vocab_grammar_exercise(exercise_type_id); +CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx + ON community.vocab_grammar_exercise_progress(user_id); +CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx + ON community.vocab_grammar_exercise_progress(exercise_id); + +-- ============================================ +-- 4. Standard-Daten einfügen +-- ============================================ + +-- Standard-Übungstypen für Grammatik +INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES + ('gap_fill', 'Lückentext-Übung'), + ('multiple_choice', 'Multiple-Choice-Fragen'), + ('sentence_building', 'Satzbau-Übung'), + ('transformation', 'Satzumformung'), + ('conjugation', 'Konjugations-Übung'), + ('declension', 'Deklinations-Übung') +ON CONFLICT (name) DO NOTHING; + +-- ============================================ +-- 5. Kommentare hinzufügen (optional) +-- ============================================ + +COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS + 'Type: vocab, grammar, conversation, culture, review'; +COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS + 'Zielzeit in Minuten für diese Lektion'; +COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS + 'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)'; +COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS + 'Muss diese Lektion wiederholt werden, wenn Ziel nicht erreicht?'; + +-- ============================================ +-- Fertig! +-- ============================================ +-- Alle Tabellen, Indizes und Standard-Daten wurden erstellt. +-- Du kannst jetzt Kurse erstellen und verwenden. diff --git a/backend/sql/update-vocab-courses-existing.sql b/backend/sql/update-vocab-courses-existing.sql new file mode 100644 index 0000000..0b94936 --- /dev/null +++ b/backend/sql/update-vocab-courses-existing.sql @@ -0,0 +1,131 @@ +-- ============================================ +-- Vocab Courses - Update für bestehende Installation +-- ============================================ +-- Führe diese Queries aus, wenn die Tabellen bereits existieren +-- (z.B. wenn nur die Basis-Tabellen erstellt wurden) + +-- ============================================ +-- 1. chapter_id optional machen +-- ============================================ +ALTER TABLE community.vocab_course_lesson +ALTER COLUMN chapter_id DROP NOT NULL; + +-- ============================================ +-- 2. Neue Spalten zu vocab_course_lesson hinzufügen +-- ============================================ +ALTER TABLE community.vocab_course_lesson +ADD COLUMN IF NOT EXISTS week_number INTEGER, +ADD COLUMN IF NOT EXISTS day_number INTEGER, +ADD COLUMN IF NOT EXISTS lesson_type TEXT DEFAULT 'vocab', +ADD COLUMN IF NOT EXISTS audio_url TEXT, +ADD COLUMN IF NOT EXISTS cultural_notes TEXT, +ADD COLUMN IF NOT EXISTS target_minutes INTEGER, +ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80, +ADD COLUMN IF NOT EXISTS requires_review BOOLEAN DEFAULT false; + +-- ============================================ +-- 3. Neue Indizes hinzufügen +-- ============================================ +CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx + ON community.vocab_course_lesson(course_id, week_number); +CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx + ON community.vocab_course_lesson(lesson_type); + +-- ============================================ +-- 4. Grammatik-Übungstabellen erstellen (falls noch nicht vorhanden) +-- ============================================ + +-- Grammatik-Übungstypen +CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Grammatik-Übungen +CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise ( + id SERIAL PRIMARY KEY, + lesson_id INTEGER NOT NULL, + exercise_type_id INTEGER NOT NULL, + exercise_number INTEGER NOT NULL, + title TEXT NOT NULL, + instruction TEXT, + question_data JSONB NOT NULL, + answer_data JSONB NOT NULL, + explanation TEXT, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_grammar_exercise_lesson_fk + FOREIGN KEY (lesson_id) + REFERENCES community.vocab_course_lesson(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_type_fk + FOREIGN KEY (exercise_type_id) + REFERENCES community.vocab_grammar_exercise_type(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number) +); + +-- Fortschritt für Grammatik-Übungen +CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + exercise_id INTEGER NOT NULL, + attempts INTEGER DEFAULT 0, + correct_attempts INTEGER DEFAULT 0, + last_attempt_at TIMESTAMP WITHOUT TIME ZONE, + completed BOOLEAN DEFAULT false, + completed_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT vocab_grammar_exercise_progress_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_progress_exercise_fk + FOREIGN KEY (exercise_id) + REFERENCES community.vocab_grammar_exercise(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id) +); + +-- Indizes für Grammatik-Übungen +CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx + ON community.vocab_grammar_exercise(lesson_id); +CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx + ON community.vocab_grammar_exercise(exercise_type_id); +CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx + ON community.vocab_grammar_exercise_progress(user_id); +CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx + ON community.vocab_grammar_exercise_progress(exercise_id); + +-- ============================================ +-- 5. Standard-Daten einfügen +-- ============================================ +INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES + ('gap_fill', 'Lückentext-Übung'), + ('multiple_choice', 'Multiple-Choice-Fragen'), + ('sentence_building', 'Satzbau-Übung'), + ('transformation', 'Satzumformung'), + ('conjugation', 'Konjugations-Übung'), + ('declension', 'Deklinations-Übung') +ON CONFLICT (name) DO NOTHING; + +-- ============================================ +-- 6. Kommentare hinzufügen +-- ============================================ +COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS + 'Type: vocab, grammar, conversation, culture, review'; +COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS + 'Zielzeit in Minuten für diese Lektion'; +COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS + 'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)'; +COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS + 'Muss diese Lektion wiederholt werden, wenn Ziel nicht erreicht?'; + +-- ============================================ +-- Fertig! +-- ============================================ diff --git a/backend/utils/syncDatabase.js b/backend/utils/syncDatabase.js index c67ece1..a2ed45b 100644 --- a/backend/utils/syncDatabase.js +++ b/backend/utils/syncDatabase.js @@ -144,8 +144,201 @@ const syncDatabase = async () => { ON community.vocab_chapter_lexeme(learning_lexeme_id); CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx ON community.vocab_chapter_lexeme(reference_lexeme_id); + + // Kurs-Tabellen + CREATE TABLE IF NOT EXISTS community.vocab_course ( + id SERIAL PRIMARY KEY, + owner_user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + language_id INTEGER NOT NULL, + difficulty_level INTEGER DEFAULT 1, + is_public BOOLEAN DEFAULT false, + share_code TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_owner_fk + FOREIGN KEY (owner_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_share_code_uniq UNIQUE (share_code) + ); + + CREATE TABLE IF NOT EXISTS community.vocab_course_lesson ( + id SERIAL PRIMARY KEY, + course_id INTEGER NOT NULL, + chapter_id INTEGER, + lesson_number INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + week_number INTEGER, + day_number INTEGER, + lesson_type TEXT DEFAULT 'vocab', + audio_url TEXT, + cultural_notes TEXT, + target_minutes INTEGER, + target_score_percent INTEGER DEFAULT 80, + requires_review BOOLEAN DEFAULT false, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_lesson_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_lesson_chapter_fk + FOREIGN KEY (chapter_id) + REFERENCES community.vocab_chapter(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_lesson_unique UNIQUE (course_id, lesson_number) + ); + + CREATE TABLE IF NOT EXISTS community.vocab_course_enrollment ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + course_id INTEGER NOT NULL, + enrolled_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_course_enrollment_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_enrollment_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_enrollment_unique UNIQUE (user_id, course_id) + ); + + CREATE TABLE IF NOT EXISTS community.vocab_course_progress ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + course_id INTEGER NOT NULL, + lesson_id INTEGER NOT NULL, + completed BOOLEAN DEFAULT false, + score INTEGER DEFAULT 0, + last_accessed_at TIMESTAMP WITHOUT TIME ZONE, + completed_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT vocab_course_progress_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_course_fk + FOREIGN KEY (course_id) + REFERENCES community.vocab_course(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_lesson_fk + FOREIGN KEY (lesson_id) + REFERENCES community.vocab_course_lesson(id) + ON DELETE CASCADE, + CONSTRAINT vocab_course_progress_unique UNIQUE (user_id, lesson_id) + ); + + CREATE INDEX IF NOT EXISTS vocab_course_owner_idx + ON community.vocab_course(owner_user_id); + CREATE INDEX IF NOT EXISTS vocab_course_language_idx + ON community.vocab_course(language_id); + CREATE INDEX IF NOT EXISTS vocab_course_public_idx + ON community.vocab_course(is_public); + CREATE INDEX IF NOT EXISTS vocab_course_lesson_course_idx + ON community.vocab_course_lesson(course_id); + CREATE INDEX IF NOT EXISTS vocab_course_lesson_chapter_idx + ON community.vocab_course_lesson(chapter_id); + CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx + ON community.vocab_course_lesson(course_id, week_number); + CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx + ON community.vocab_course_lesson(lesson_type); + CREATE INDEX IF NOT EXISTS vocab_course_enrollment_user_idx + ON community.vocab_course_enrollment(user_id); + CREATE INDEX IF NOT EXISTS vocab_course_enrollment_course_idx + ON community.vocab_course_enrollment(course_id); + CREATE INDEX IF NOT EXISTS vocab_course_progress_user_idx + ON community.vocab_course_progress(user_id); + CREATE INDEX IF NOT EXISTS vocab_course_progress_course_idx + ON community.vocab_course_progress(course_id); + CREATE INDEX IF NOT EXISTS vocab_course_progress_lesson_idx + ON community.vocab_course_progress(lesson_id); + + // Grammatik-Übungstypen + CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW() + ); + + // Grammatik-Übungen + CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise ( + id SERIAL PRIMARY KEY, + lesson_id INTEGER NOT NULL, + exercise_type_id INTEGER NOT NULL, + exercise_number INTEGER NOT NULL, + title TEXT NOT NULL, + instruction TEXT, + question_data JSONB NOT NULL, + answer_data JSONB NOT NULL, + explanation TEXT, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_grammar_exercise_lesson_fk + FOREIGN KEY (lesson_id) + REFERENCES community.vocab_course_lesson(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_type_fk + FOREIGN KEY (exercise_type_id) + REFERENCES community.vocab_grammar_exercise_type(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number) + ); + + // Fortschritt für Grammatik-Übungen + CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + exercise_id INTEGER NOT NULL, + attempts INTEGER DEFAULT 0, + correct_attempts INTEGER DEFAULT 0, + last_attempt_at TIMESTAMP WITHOUT TIME ZONE, + completed BOOLEAN DEFAULT false, + completed_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT vocab_grammar_exercise_progress_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_progress_exercise_fk + FOREIGN KEY (exercise_id) + REFERENCES community.vocab_grammar_exercise(id) + ON DELETE CASCADE, + CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id) + ); + + CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx + ON community.vocab_grammar_exercise(lesson_id); + CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx + ON community.vocab_grammar_exercise(exercise_type_id); + CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx + ON community.vocab_grammar_exercise_progress(user_id); + CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx + ON community.vocab_grammar_exercise_progress(exercise_id); + + -- Standard-Übungstypen einfügen + INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES + ('gap_fill', 'Lückentext-Übung'), + ('multiple_choice', 'Multiple-Choice-Fragen'), + ('sentence_building', 'Satzbau-Übung'), + ('transformation', 'Satzumformung'), + ('conjugation', 'Konjugations-Übung'), + ('declension', 'Deklinations-Übung') + ON CONFLICT (name) DO NOTHING; `); console.log("✅ Vocab-Trainer Tabellen sind vorhanden."); + console.log("✅ Vocab-Course Tabellen sind vorhanden."); + console.log("✅ Vocab-Grammar-Exercise Tabellen sind vorhanden."); } catch (e) { console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e); } diff --git a/docs/BISAYA_4_WEEK_COURSE.md b/docs/BISAYA_4_WEEK_COURSE.md new file mode 100644 index 0000000..bfb7a0e --- /dev/null +++ b/docs/BISAYA_4_WEEK_COURSE.md @@ -0,0 +1,306 @@ +# Bisaya (Cebuano) 4-Wochen-Kurs - Vollständige Struktur + +## Kurs-Übersicht + +**Titel:** Bisaya für Familien - Schnellstart in 4 Wochen +**Ziel:** In 4 Wochen alltagstauglich Bisaya sprechen können +**Zeitaufwand:** ~30-40 Minuten pro Tag +**Schwierigkeit:** Anfänger (Level 1) + +## Lernziele & System + +### Zeitvorgaben pro Lektionstyp: +- **Vokabeln:** 20-25 Minuten pro Tag +- **Konversation:** 15-20 Minuten pro Tag +- **Grammatik:** 20-25 Minuten pro Tag +- **Wiederholung:** 30 Minuten pro Tag +- **Tests:** 15-20 Minuten + +### Zielpunktzahlen: +- **Vokabeln:** 85% zum Abschluss (sonst Wiederholung erforderlich) +- **Konversation:** 80% zum Abschluss +- **Grammatik:** 75% zum Abschluss +- **Tests:** 80% zum Abschluss + +### Wiederholungslogik: +- Lektionen mit `requiresReview: true` müssen wiederholt werden, wenn das Ziel nicht erreicht wird +- Vokabel-Lektionen haben standardmäßig Wiederholung aktiviert +- Konversations-Lektionen können ohne Wiederholung abgeschlossen werden + +## Woche 1: Grundlagen & Aussprache + +### Tag 1 (35 Min) +1. **Begrüßungen & Höflichkeit** (15 Min, 80%, conversation) + - Kumusta? – Wie geht's? + - Maayo – Gut + - Salamat – Danke + - Palihug – Bitte + - Kulturell: Philippiner schätzen Höflichkeit sehr. Lächeln ist wichtig! + +2. **Überlebenssätze - Teil 1** (20 Min, 85%, vocab, Wiederholung) + - Die 10 wichtigsten Sätze für den Alltag + - Wala ko kasabot – Ich verstehe nicht + - Hinay-hinay lang – Bitte langsam + - Asa ang …? – Wo ist …? + +### Tag 2 (35 Min) +3. **Familienwörter** (20 Min, 85%, vocab, Wiederholung) + - Mama / Nanay – Mutter + - Papa / Tatay – Vater + - Kuya – älterer Bruder + - Ate – ältere Schwester + - Lola / Lolo – Oma / Opa + - Kulturell: Kuya und Ate werden auch für Nicht-Verwandte verwendet! + +4. **Familien-Gespräche** (15 Min, 80%, conversation) + - Einfache Gespräche mit Familienmitgliedern + - Kumusta mo? – Wie geht es dir? + +### Tag 3 (35 Min) +5. **Gefühle & Zuneigung** (15 Min, 80%, conversation) + - Mingaw ko nimo – Ich vermisse dich + - Nalipay ko nga makita ka – Ich freue mich, dich zu sehen + - Ganahan ko nimo – Ich mag dich + - Palangga taka – Ich hab dich lieb ❤️ + - Kulturell: Palangga taka ist wärmer als "I love you"! + +6. **Überlebenssätze - Teil 2** (20 Min, 85%, vocab, Wiederholung) + - Weitere wichtige Alltagssätze + - Unsa ni? – Was ist das? + - Oo / Dili – Ja / Nein + +### Tag 4 (35 Min) +7. **Essen & Fürsorge** (15 Min, 80%, conversation) + - Nikaon ka? – Hast du schon gegessen? + - Kaon ta – Lass uns essen + - Lami – Lecker + - Kulturell: Essen = Liebe! "Nikaon na ka?" ist sehr fürsorglich. + +8. **Essen & Trinken** (20 Min, 85%, vocab, Wiederholung) + - Wichtige Wörter rund ums Essen + +### Tag 5 (45 Min) +9. **Woche 1 - Wiederholung** (30 Min, 80%, review) + - Wiederhole alle Inhalte der ersten Woche + +10. **Woche 1 - Vokabeltest** (15 Min, 80%, vocab, Wiederholung) + - Teste dein Wissen aus Woche 1 + +## Woche 2: Alltag & Familie + +### Tag 6 (35 Min) +11. **Alltagsgespräche - Teil 1** (15 Min, 80%, conversation) + - Kumusta ang imong adlaw? – Wie war dein Tag? + - Unsa imong ginabuhat? – Was machst du? + +12. **Haus & Familie** (20 Min, 85%, vocab, Wiederholung) + - Balay – Haus + - Kwarto – Zimmer + - Kusina – Küche + - Pamilya – Familie + +### Tag 7 (35 Min) +13. **Alltagsgespräche - Teil 2** (15 Min, 80%, conversation) + - Asa ka padulong? – Wohin gehst du? + - Unsa imong plano? – Was ist dein Plan? + +14. **Ort & Richtung** (20 Min, 85%, vocab, Wiederholung) + - Asa – Wo + - dinhi – hier + - didto – dort + - padulong – gehen zu + +### Tag 8 (45 Min) +15. **Zeitformen - Grundlagen** (25 Min, 75%, grammar, Wiederholung) + - Ni-kaon ko → Ich habe gegessen + - Mo-kaon ko → Ich werde essen + - Kulturell: Cebuano hat keine komplexen Zeiten wie Deutsch! + +16. **Zeit & Datum** (20 Min, 85%, vocab, Wiederholung) + - Karon – jetzt + - ugma – morgen + - gahapon – gestern + - karon adlaw – heute + +### Tag 9 (40 Min) +17. **Einkaufen & Preise** (15 Min, 80%, conversation) + - Tagpila ni? – Wie viel kostet das? + - Pwede barato? – Kann es billiger sein? + - Kulturell: Handeln ist in den Philippinen üblich! + +18. **Zahlen & Preise** (25 Min, 85%, vocab, Wiederholung) + - Zahlen 1-100 + - Preise und Mengen + +### Tag 10 (45 Min) +19. **Woche 2 - Wiederholung** (30 Min, 80%, review) + - Wiederhole alle Inhalte der zweiten Woche + +20. **Woche 2 - Vokabeltest** (15 Min, 80%, vocab, Wiederholung) + - Teste dein Wissen aus Woche 2 + +## Woche 3: Vertiefung + +### Tag 11 (35 Min) +21. **Gefühle & Emotionen** (15 Min, 80%, conversation) + - Nalipay – glücklich + - nasubo – traurig + - nahadlok – ängstlich + +22. **Gefühle & Emotionen** (20 Min, 85%, vocab, Wiederholung) + - Wörter für verschiedene Gefühle + +### Tag 12 (35 Min) +23. **Gesundheit & Wohlbefinden** (15 Min, 80%, conversation) + - Sakit – Schmerz/Krankheit + - maayo – gut + - tambal – Medizin + - doktor – Arzt + +24. **Körper & Gesundheit** (20 Min, 85%, vocab, Wiederholung) + - Wörter rund um den Körper + +### Tag 13 (35 Min) +25. **Höflichkeitsformen** (20 Min, 75%, grammar, Wiederholung) + - Palihug – Bitte + - Pwede – Kann ich? + - Tabang – Hilfe + - Kulturell: Höflichkeit ist extrem wichtig! + +26. **Bitten & Fragen** (15 Min, 80%, conversation) + - Wie man höflich fragt und bittet + +### Tag 14 (35 Min) +27. **Kinder & Familie** (15 Min, 80%, conversation) + - Gespräche mit und über Kinder + - Kulturell: Kinder sind sehr wichtig! + +28. **Kinder & Spiel** (20 Min, 85%, vocab, Wiederholung) + - Wörter für Kinder und Spielsachen + +### Tag 15 (45 Min) +29. **Woche 3 - Wiederholung** (30 Min, 80%, review) + - Wiederhole alle Inhalte der dritten Woche + +30. **Woche 3 - Vokabeltest** (15 Min, 80%, vocab, Wiederholung) + - Teste dein Wissen aus Woche 3 + +## Woche 4: Freies Sprechen + +### Tag 16 (45 Min) +31. **Freies Gespräch - Thema 1** (20 Min, 75%, conversation) + - Übe freies Sprechen zu verschiedenen Themen + - Kulturell: Fehler sind okay! Philippiner schätzen das Bemühen. + +32. **Wiederholung - Woche 1 & 2** (25 Min, 85%, vocab, Wiederholung) + - Wiederhole wichtige Vokabeln + +### Tag 17 (45 Min) +33. **Freies Gespräch - Thema 2** (20 Min, 75%, conversation) + - Weitere Übung im freien Sprechen + +34. **Wiederholung - Woche 3** (25 Min, 85%, vocab, Wiederholung) + - Wiederhole wichtige Vokabeln aus Woche 3 + +### Tag 18 (55 Min) +35. **Komplexere Gespräche** (25 Min, 75%, conversation) + - Längere Gespräche zu verschiedenen Themen + - Kulturell: Je mehr du sprichst, desto besser wirst du! + +36. **Gesamtwiederholung** (30 Min, 80%, review) + - Wiederhole alle wichtigen Inhalte + +### Tag 19 (45 Min) +37. **Praktische Übung** (25 Min, 75%, conversation) + - Simuliere echte Gesprächssituationen + +38. **Abschlusstest - Vokabeln** (20 Min, 80%, vocab, Wiederholung) + - Finaler Vokabeltest über den gesamten Kurs + +### Tag 20 (45 Min) +39. **Abschlussprüfung** (30 Min, 80%, review) + - Finale Prüfung über alle Kursinhalte + - Kulturell: Gratulation zum Abschluss! + +40. **Kulturelle Tipps & Tricks** (15 Min, 0%, culture) + - Wichtige kulturelle Hinweise für den Alltag + - Kulturelles Verständnis ist genauso wichtig wie die Sprache! + +## Kurs-Statistik + +- **Gesamte Lektionen:** 40 +- **Vokabel-Lektionen:** 15 (mit Wiederholung) +- **Konversations-Lektionen:** 15 +- **Grammatik-Lektionen:** 2 +- **Wiederholungs-Lektionen:** 6 +- **Kultur-Lektionen:** 1 +- **Test-Lektionen:** 1 +- **Durchschnittliche Zeit pro Tag:** ~38 Minuten +- **Gesamtzeit:** ~15 Stunden über 4 Wochen + +## Verwendung des Scripts + +```bash +# 1. Erstelle zuerst die Sprache "Cebuano" oder "Bisaya" im System +# 2. Notiere dir die languageId und deine ownerHashedId +# 3. Führe das Script aus: + +node backend/scripts/create-bisaya-course.js + +# Beispiel: +node backend/scripts/create-bisaya-course.js 1 abc123def456 +``` + +Das Script erstellt automatisch: +- Den Kurs mit allen 40 Lektionen +- Wochen- und Tagesstruktur +- Zeitvorgaben und Zielpunktzahlen +- Wiederholungslogik +- Kulturelle Notizen + +## Nächste Schritte nach Kurs-Erstellung + +1. **Vokabeln hinzufügen:** + - Erstelle Kapitel für jede Vokabel-Lektion + - Füge die entsprechenden Vokabeln hinzu + - Verknüpfe die Kapitel mit den Lektionen + +2. **Grammatik-Übungen erstellen:** + - Erstelle Grammatik-Übungen für Lektion 15 und 25 + - Nutze verschiedene Übungstypen (transformation, multiple_choice) + +3. **Audio hinzufügen:** + - Füge Audio-URLs zu Konversations-Lektionen hinzu + - Verlinke zu YouTube-Videos oder eigenen Audio-Dateien + +4. **Kurs teilen:** + - Nutze den Share-Code, um den Kurs mit anderen zu teilen + - Oder mache den Kurs öffentlich + +## Lernstrategie + +### Täglich: +1. **Vokabeln lernen** (20-25 Min) + - Nutze die Vokabeltrainer-Funktion + - Wiederhole bis 85% erreicht sind + +2. **Konversation üben** (15-20 Min) + - Höre Audio + - Sprich laut nach + - Übe mit Muttersprachlern (HelloTalk, Tandem) + +3. **Grammatik verstehen** (wenn vorhanden, 20-25 Min) + - Mache die Grammatik-Übungen + - Verstehe die Regeln + +### Wöchentlich: +- Wiederholungslektion am Ende jeder Woche +- Vokabeltest am Ende jeder Woche +- Prüfe deinen Fortschritt + +### Am Ende: +- Abschlussprüfung +- Kulturelle Tipps lesen +- Weiter üben mit Muttersprachlern! + +Viel Erfolg beim Lernen! 🇵🇭 diff --git a/docs/BISAYA_COURSE_EXAMPLE.md b/docs/BISAYA_COURSE_EXAMPLE.md new file mode 100644 index 0000000..d88eaff --- /dev/null +++ b/docs/BISAYA_COURSE_EXAMPLE.md @@ -0,0 +1,186 @@ +# Bisaya (Cebuano) Sprachkurs - Beispiel-Struktur + +Dieses Dokument zeigt, wie du einen strukturierten Sprachkurs wie den beschriebenen Bisaya-Kurs im System erstellst. + +## Kurs-Übersicht + +**Titel:** Bisaya für Familien - Schnellstart in 30 Tagen +**Sprache:** Cebuano (Bisaya) +**Schwierigkeit:** 1-2 +**Zielgruppe:** Familienmitglieder, die schnell alltagstauglich sprechen wollen + +## Kursstruktur + +### Woche 1: Grundlagen & Aussprache + +#### Tag 1: Begrüßungen & Höflichkeit +- **Lektionstyp:** conversation +- **Inhalt:** + - Kumusta? – Wie geht's? + - Maayo – Gut + - Salamat – Danke + - Palihug – Bitte +- **Kulturelle Notizen:** Philippiner schätzen Höflichkeit sehr. Lächeln ist wichtig! + +#### Tag 2: Familienwörter +- **Lektionstyp:** vocab +- **Inhalt:** + - Mama / Nanay – Mutter + - Papa / Tatay – Vater + - Kuya – älterer Bruder + - Ate – ältere Schwester + - Lola / Lolo – Oma / Opa +- **Kulturelle Notizen:** Kuya und Ate werden auch für Nicht-Verwandte verwendet – sehr respektvoll! + +#### Tag 3: Überlebenssätze +- **Lektionstyp:** conversation +- **Inhalt:** + - Wala ko kasabot – Ich verstehe nicht + - Hinay-hinay lang – Bitte langsam + - Asa ang …? – Wo ist …? + - Unsa ni? – Was ist das? + +#### Tag 4: Gefühle & Zuneigung +- **Lektionstyp:** conversation +- **Inhalt:** + - Mingaw ko nimo – Ich vermisse dich + - Nalipay ko nga makita ka – Ich freue mich, dich zu sehen + - Ganahan ko nimo – Ich mag dich + - Palangga taka – Ich hab dich lieb ❤️ +- **Kulturelle Notizen:** Palangga taka ist wärmer als "I love you" im Familienkontext. + +#### Tag 5: Essen & Fürsorge +- **Lektionstyp:** conversation +- **Inhalt:** + - Nikaon ka? – Hast du schon gegessen? + - Kaon ta – Lass uns essen + - Lami – Lecker +- **Kulturelle Notizen:** Essen = Liebe! "Nikaon na ka?" ist sehr fürsorglich. + +### Woche 2: Alltag & Familie + +#### Tag 6: Alltagsgespräche +- **Lektionstyp:** conversation +- **Inhalt:** + - Kumusta ang imong adlaw? – Wie war dein Tag? + - Unsa imong ginabuhat? – Was machst du? + - Asa ka padulong? – Wohin gehst du? + +#### Tag 7: Haus & Familie +- **Lektionstyp:** vocab +- **Inhalt:** + - Balay – Haus + - Kwarto – Zimmer + - Kusina – Küche + - Pamilya – Familie + +### Woche 3: Vertiefung + +#### Tag 8-14: Praktische Gespräche +- Verschiedene Alltagssituationen +- Grammatik-Minimal (nur das Nötigste) +- Mehr Familienkonversationen + +### Woche 4: Freies Sprechen + +#### Tag 15-21: Übung & Anwendung +- Freie Gespräche +- Fehler zulassen +- Viel lachen 😄 + +## Grammatik-Übungen + +### Übung 1: Zeitformen (Woche 2) +- **Typ:** transformation +- **Frage:** "Ni-kaon ko" bedeutet? +- **Antwort:** "Ich habe gegessen" +- **Erklärung:** Cebuano hat keine komplexen Zeiten wie Deutsch. Zeit wird mit Präfixen ausgedrückt. + +### Übung 2: Höflichkeit (Woche 1) +- **Typ:** multiple_choice +- **Frage:** Wie sagt man "Bitte langsam"? +- **Antworten:** + - Hinay-hinay lang ✓ + - Palihug + - Salamat + +## Praktische Tipps für Kurs-Erstellung + +1. **Strukturierung:** + - Verwende `weekNumber` und `dayNumber` für klare Struktur + - Setze `lessonType` auf: `vocab`, `grammar`, `conversation`, `culture`, `review` + +2. **Kulturelle Notizen:** + - Nutze `culturalNotes` für wichtige kulturelle Hinweise + - Diese helfen Lernenden, die Sprache im Kontext zu verstehen + +3. **Audio:** + - Füge `audioUrl` hinzu für Aussprache-Übungen + - Verlinke zu YouTube-Videos oder eigenen Audio-Dateien + +4. **Grammatik-Übungen:** + - Erstelle Grammatik-Übungen für wichtige Konzepte + - Nutze verschiedene Übungstypen (gap_fill, multiple_choice, transformation) + +5. **Fortschritt:** + - Das System verfolgt automatisch den Fortschritt + - Lernende sehen, welche Lektionen sie abgeschlossen haben + +## Beispiel-API-Aufrufe + +### Kurs erstellen: +```javascript +POST /api/vocab/courses +{ + "title": "Bisaya für Familien - Schnellstart", + "description": "Lerne Bisaya schnell und praktisch für den Familienalltag", + "languageId": 1, + "difficultyLevel": 1, + "isPublic": true +} +``` + +### Lektion hinzufügen: +```javascript +POST /api/vocab/courses/{courseId}/lessons +{ + "lessonNumber": 1, + "title": "Begrüßungen & Höflichkeit", + "description": "Lerne die wichtigsten Begrüßungen", + "weekNumber": 1, + "dayNumber": 1, + "lessonType": "conversation", + "culturalNotes": "Philippiner schätzen Höflichkeit sehr. Lächeln ist wichtig!", + "chapterId": null // Optional, wenn du Vokabeln aus einem Kapitel verwenden willst +} +``` + +### Grammatik-Übung hinzufügen: +```javascript +POST /api/vocab/lessons/{lessonId}/grammar-exercises +{ + "exerciseTypeId": 4, // transformation + "exerciseNumber": 1, + "title": "Zeitformen verstehen", + "instruction": "Was bedeutet 'Ni-kaon ko'?", + "questionData": { + "text": "Ni-kaon ko", + "type": "transformation" + }, + "answerData": { + "correct": "Ich habe gegessen", + "alternatives": ["I ate", "I have eaten"] + }, + "explanation": "Cebuano hat keine komplexen Zeiten wie Deutsch. Zeit wird mit Präfixen ausgedrückt." +} +``` + +## Nächste Schritte + +1. Erstelle die Sprache "Cebuano" im Vokabeltrainer +2. Erstelle den Kurs mit der obigen Struktur +3. Füge Lektionen mit Wochen/Tagen hinzu +4. Erstelle Grammatik-Übungen für wichtige Konzepte +5. Teile den Kurs mit anderen (share_code) + +Viel Erfolg beim Lernen! 🇵🇭 diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index 24e20b4..30016eb 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -319,6 +319,35 @@ "search": "Suchen", "noResults": "Keine Treffer.", "error": "Suche fehlgeschlagen." + }, + "courses": { + "title": "Sprachlernkurse", + "create": "Kurs erstellen", + "myCourses": "Meine Kurse", + "allCourses": "Alle Kurse", + "none": "Keine Kurse gefunden.", + "owner": "Besitzer", + "enrolled": "Eingeschrieben", + "public": "Öffentlich", + "difficulty": "Schwierigkeit", + "lessons": "Lektionen", + "enroll": "Einschreiben", + "continue": "Fortsetzen", + "edit": "Bearbeiten", + "addLesson": "Lektion hinzufügen", + "completed": "Abgeschlossen", + "score": "Punktzahl", + "review": "Wiederholen", + "start": "Starten", + "noLessons": "Dieser Kurs hat noch keine Lektionen.", + "lessonNumber": "Lektionsnummer", + "chapter": "Kapitel", + "selectChapter": "Kapitel auswählen", + "selectLanguage": "Sprache auswählen", + "confirmDelete": "Lektion wirklich löschen?", + "titleLabel": "Titel", + "descriptionLabel": "Beschreibung", + "languageLabel": "Sprache" } } } diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index 76d2b8d..5275922 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -319,6 +319,35 @@ "search": "Search", "noResults": "No results.", "error": "Search failed." + }, + "courses": { + "title": "Language Learning Courses", + "create": "Create Course", + "myCourses": "My Courses", + "allCourses": "All Courses", + "none": "No courses found.", + "owner": "Owner", + "enrolled": "Enrolled", + "public": "Public", + "difficulty": "Difficulty", + "lessons": "Lessons", + "enroll": "Enroll", + "continue": "Continue", + "edit": "Edit", + "addLesson": "Add Lesson", + "completed": "Completed", + "score": "Score", + "review": "Review", + "start": "Start", + "noLessons": "This course has no lessons yet.", + "lessonNumber": "Lesson Number", + "chapter": "Chapter", + "selectChapter": "Select Chapter", + "selectLanguage": "Select Language", + "confirmDelete": "Really delete lesson?", + "titleLabel": "Title", + "descriptionLabel": "Description", + "languageLabel": "Language" } } } diff --git a/frontend/src/router/socialRoutes.js b/frontend/src/router/socialRoutes.js index 82d17ab..7555dc1 100644 --- a/frontend/src/router/socialRoutes.js +++ b/frontend/src/router/socialRoutes.js @@ -10,6 +10,8 @@ import VocabNewLanguageView from '../views/social/VocabNewLanguageView.vue'; import VocabLanguageView from '../views/social/VocabLanguageView.vue'; import VocabSubscribeView from '../views/social/VocabSubscribeView.vue'; import VocabChapterView from '../views/social/VocabChapterView.vue'; +import VocabCourseListView from '../views/social/VocabCourseListView.vue'; +import VocabCourseView from '../views/social/VocabCourseView.vue'; const socialRoutes = [ { @@ -84,6 +86,19 @@ const socialRoutes = [ component: VocabChapterView, meta: { requiresAuth: true } }, + { + path: '/socialnetwork/vocab/courses', + name: 'VocabCourses', + component: VocabCourseListView, + meta: { requiresAuth: true } + }, + { + path: '/socialnetwork/vocab/courses/:courseId', + name: 'VocabCourse', + component: VocabCourseView, + props: true, + meta: { requiresAuth: true } + }, ]; export default socialRoutes; diff --git a/frontend/src/views/social/VocabCourseListView.vue b/frontend/src/views/social/VocabCourseListView.vue new file mode 100644 index 0000000..1b22cde --- /dev/null +++ b/frontend/src/views/social/VocabCourseListView.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/frontend/src/views/social/VocabCourseView.vue b/frontend/src/views/social/VocabCourseView.vue new file mode 100644 index 0000000..3fa23ed --- /dev/null +++ b/frontend/src/views/social/VocabCourseView.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/frontend/src/views/social/VocabTrainerView.vue b/frontend/src/views/social/VocabTrainerView.vue index 3a5ea1f..7396504 100644 --- a/frontend/src/views/social/VocabTrainerView.vue +++ b/frontend/src/views/social/VocabTrainerView.vue @@ -6,6 +6,7 @@
+
{{ $t('general.loading') }}
@@ -43,6 +44,9 @@ export default { goNewLanguage() { this.$router.push('/socialnetwork/vocab/new'); }, + goCourses() { + this.$router.push('/socialnetwork/vocab/courses'); + }, openLanguage(id) { this.$router.push(`/socialnetwork/vocab/${id}`); },