Implement vocab course and grammar exercise features in backend and frontend
- Added new course management functionalities in VocabController, including creating, updating, and deleting courses and lessons. - Implemented enrollment and progress tracking for courses, along with grammar exercise creation and management. - Updated database schema to include tables for courses, lessons, enrollments, and grammar exercises. - Enhanced frontend with new routes and views for course listing and details, including internationalization support for course-related texts. - Improved user experience by adding navigation to courses from the main vocab trainer view.
This commit is contained in:
@@ -21,6 +21,37 @@ class VocabController {
|
|||||||
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
|
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.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 });
|
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 } = {}) {
|
_wrapWithUser(fn, { successStatus = 200 } = {}) {
|
||||||
|
|||||||
132
backend/migrations/20260115000000-add-vocab-courses.cjs
Normal file
132
backend/migrations/20260115000000-add-vocab-courses.cjs
Normal file
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
47
backend/migrations/20260115000002-add-course-structure.cjs
Normal file
47
backend/migrations/20260115000002-add-course-structure.cjs
Normal file
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -104,6 +104,13 @@ import Weather from './falukant/data/weather.js';
|
|||||||
import ProductWeatherEffect from './falukant/type/product_weather_effect.js';
|
import ProductWeatherEffect from './falukant/type/product_weather_effect.js';
|
||||||
import Blog from './community/blog.js';
|
import Blog from './community/blog.js';
|
||||||
import BlogPost from './community/blog_post.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 Campaign from './match3/campaign.js';
|
||||||
import Match3Level from './match3/level.js';
|
import Match3Level from './match3/level.js';
|
||||||
import Objective from './match3/objective.js';
|
import Objective from './match3/objective.js';
|
||||||
@@ -941,5 +948,37 @@ export default function setupAssociations() {
|
|||||||
|
|
||||||
TaxiHighscore.belongsTo(TaxiMap, { foreignKey: 'mapId', as: 'map' });
|
TaxiHighscore.belongsTo(TaxiMap, { foreignKey: 'mapId', as: 'map' });
|
||||||
TaxiMap.hasMany(TaxiHighscore, { foreignKey: 'mapId', as: 'highscores' });
|
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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
69
backend/models/community/vocab_course.js
Normal file
69
backend/models/community/vocab_course.js
Normal file
@@ -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;
|
||||||
37
backend/models/community/vocab_course_enrollment.js
Normal file
37
backend/models/community/vocab_course_enrollment.js
Normal file
@@ -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;
|
||||||
93
backend/models/community/vocab_course_lesson.js
Normal file
93
backend/models/community/vocab_course_lesson.js
Normal file
@@ -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;
|
||||||
56
backend/models/community/vocab_course_progress.js
Normal file
56
backend/models/community/vocab_course_progress.js
Normal file
@@ -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;
|
||||||
69
backend/models/community/vocab_grammar_exercise.js
Normal file
69
backend/models/community/vocab_grammar_exercise.js
Normal file
@@ -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;
|
||||||
57
backend/models/community/vocab_grammar_exercise_progress.js
Normal file
57
backend/models/community/vocab_grammar_exercise_progress.js
Normal file
@@ -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;
|
||||||
36
backend/models/community/vocab_grammar_exercise_type.js
Normal file
36
backend/models/community/vocab_grammar_exercise_type.js
Normal file
@@ -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;
|
||||||
@@ -129,6 +129,15 @@ import ChatRight from './chat/rights.js';
|
|||||||
import ChatUserRight from './chat/user_rights.js';
|
import ChatUserRight from './chat/user_rights.js';
|
||||||
import RoomType from './chat/room_type.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 = {
|
const models = {
|
||||||
SettingsType,
|
SettingsType,
|
||||||
UserParamValue,
|
UserParamValue,
|
||||||
@@ -263,6 +272,15 @@ const models = {
|
|||||||
TaxiMapTileStreet,
|
TaxiMapTileStreet,
|
||||||
TaxiMapTileHouse,
|
TaxiMapTileHouse,
|
||||||
TaxiHighscore,
|
TaxiHighscore,
|
||||||
|
|
||||||
|
// Vocab Courses
|
||||||
|
VocabCourse,
|
||||||
|
VocabCourseLesson,
|
||||||
|
VocabCourseEnrollment,
|
||||||
|
VocabCourseProgress,
|
||||||
|
VocabGrammarExerciseType,
|
||||||
|
VocabGrammarExercise,
|
||||||
|
VocabGrammarExerciseProgress,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default models;
|
export default models;
|
||||||
|
|||||||
@@ -22,6 +22,37 @@ router.get('/chapters/:chapterId', vocabController.getChapter);
|
|||||||
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
||||||
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
|
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;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
309
backend/scripts/create-bisaya-course.js
Executable file
309
backend/scripts/create-bisaya-course.js
Executable file
@@ -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 <languageId> <ownerHashedId>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 <languageId> <ownerHashedId>');
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import User from '../models/community/user.js';
|
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 { sequelize } from '../utils/sequelize.js';
|
||||||
import { notifyUser } from '../utils/socket.js';
|
import { notifyUser } from '../utils/socket.js';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
export default class VocabService {
|
export default class VocabService {
|
||||||
async _getUserByHashedId(hashedUserId) {
|
async _getUserByHashedId(hashedUserId) {
|
||||||
@@ -527,6 +535,679 @@ export default class VocabService {
|
|||||||
return { created: Boolean(mapping?.id) };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
242
backend/sql/create-vocab-courses.sql
Normal file
242
backend/sql/create-vocab-courses.sql
Normal file
@@ -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.
|
||||||
131
backend/sql/update-vocab-courses-existing.sql
Normal file
131
backend/sql/update-vocab-courses-existing.sql
Normal file
@@ -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!
|
||||||
|
-- ============================================
|
||||||
@@ -144,8 +144,201 @@ const syncDatabase = async () => {
|
|||||||
ON community.vocab_chapter_lexeme(learning_lexeme_id);
|
ON community.vocab_chapter_lexeme(learning_lexeme_id);
|
||||||
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
|
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
|
||||||
ON community.vocab_chapter_lexeme(reference_lexeme_id);
|
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-Trainer Tabellen sind vorhanden.");
|
||||||
|
console.log("✅ Vocab-Course Tabellen sind vorhanden.");
|
||||||
|
console.log("✅ Vocab-Grammar-Exercise Tabellen sind vorhanden.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e);
|
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e);
|
||||||
}
|
}
|
||||||
|
|||||||
306
docs/BISAYA_4_WEEK_COURSE.md
Normal file
306
docs/BISAYA_4_WEEK_COURSE.md
Normal file
@@ -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 <languageId> <ownerHashedId>
|
||||||
|
|
||||||
|
# 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! 🇵🇭
|
||||||
186
docs/BISAYA_COURSE_EXAMPLE.md
Normal file
186
docs/BISAYA_COURSE_EXAMPLE.md
Normal file
@@ -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! 🇵🇭
|
||||||
@@ -319,6 +319,35 @@
|
|||||||
"search": "Suchen",
|
"search": "Suchen",
|
||||||
"noResults": "Keine Treffer.",
|
"noResults": "Keine Treffer.",
|
||||||
"error": "Suche fehlgeschlagen."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,35 @@
|
|||||||
"search": "Search",
|
"search": "Search",
|
||||||
"noResults": "No results.",
|
"noResults": "No results.",
|
||||||
"error": "Search failed."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import VocabNewLanguageView from '../views/social/VocabNewLanguageView.vue';
|
|||||||
import VocabLanguageView from '../views/social/VocabLanguageView.vue';
|
import VocabLanguageView from '../views/social/VocabLanguageView.vue';
|
||||||
import VocabSubscribeView from '../views/social/VocabSubscribeView.vue';
|
import VocabSubscribeView from '../views/social/VocabSubscribeView.vue';
|
||||||
import VocabChapterView from '../views/social/VocabChapterView.vue';
|
import VocabChapterView from '../views/social/VocabChapterView.vue';
|
||||||
|
import VocabCourseListView from '../views/social/VocabCourseListView.vue';
|
||||||
|
import VocabCourseView from '../views/social/VocabCourseView.vue';
|
||||||
|
|
||||||
const socialRoutes = [
|
const socialRoutes = [
|
||||||
{
|
{
|
||||||
@@ -84,6 +86,19 @@ const socialRoutes = [
|
|||||||
component: VocabChapterView,
|
component: VocabChapterView,
|
||||||
meta: { requiresAuth: true }
|
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;
|
export default socialRoutes;
|
||||||
|
|||||||
330
frontend/src/views/social/VocabCourseListView.vue
Normal file
330
frontend/src/views/social/VocabCourseListView.vue
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vocab-course-list">
|
||||||
|
<h2>{{ $t('socialnetwork.vocab.courses.title') }}</h2>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="showCreateDialog = true">{{ $t('socialnetwork.vocab.courses.create') }}</button>
|
||||||
|
<button @click="loadMyCourses">{{ $t('socialnetwork.vocab.courses.myCourses') }}</button>
|
||||||
|
<button @click="loadAllCourses">{{ $t('socialnetwork.vocab.courses.allCourses') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="courses.length === 0">
|
||||||
|
{{ $t('socialnetwork.vocab.courses.none') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="course-list">
|
||||||
|
<div v-for="course in courses" :key="course.id" class="course-item">
|
||||||
|
<div class="course-header">
|
||||||
|
<h3 @click="openCourse(course.id)" class="course-title">{{ course.title }}</h3>
|
||||||
|
<span v-if="course.isOwner" class="badge owner">{{ $t('socialnetwork.vocab.courses.owner') }}</span>
|
||||||
|
<span v-else-if="course.enrolledAt" class="badge enrolled">{{ $t('socialnetwork.vocab.courses.enrolled') }}</span>
|
||||||
|
<span v-if="course.isPublic" class="badge public">{{ $t('socialnetwork.vocab.courses.public') }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="course.description" class="course-description">{{ course.description }}</p>
|
||||||
|
<div class="course-meta">
|
||||||
|
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
|
||||||
|
<span v-if="course.lessons">{{ $t('socialnetwork.vocab.courses.lessons') }}: {{ course.lessons.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="course-actions">
|
||||||
|
<button v-if="!course.enrolledAt && (course.isPublic || course.isOwner)" @click="enroll(course.id)">
|
||||||
|
{{ $t('socialnetwork.vocab.courses.enroll') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="course.enrolledAt" @click="openCourse(course.id)">
|
||||||
|
{{ $t('socialnetwork.vocab.courses.continue') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="course.isOwner" @click="editCourse(course.id)">
|
||||||
|
{{ $t('socialnetwork.vocab.courses.edit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Course Dialog -->
|
||||||
|
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||||
|
<div class="dialog" @click.stop>
|
||||||
|
<h3>{{ $t('socialnetwork.vocab.courses.create') }}</h3>
|
||||||
|
<form @submit.prevent="createCourse">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.title') }}</label>
|
||||||
|
<input v-model="newCourse.title" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.description') }}</label>
|
||||||
|
<textarea v-model="newCourse.description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.language') }}</label>
|
||||||
|
<select v-model="newCourse.languageId" required>
|
||||||
|
<option value="">{{ $t('socialnetwork.vocab.courses.selectLanguage') }}</option>
|
||||||
|
<option v-for="lang in languages" :key="lang.id" :value="lang.id">{{ lang.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.difficulty') }}</label>
|
||||||
|
<input type="number" v-model.number="newCourse.difficultyLevel" min="1" max="10" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="newCourse.isPublic" />
|
||||||
|
{{ $t('socialnetwork.vocab.courses.public') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">{{ $t('general.create') }}</button>
|
||||||
|
<button type="button" @click="showCreateDialog = false">{{ $t('general.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import apiClient from '@/utils/axios.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'VocabCourseListView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
courses: [],
|
||||||
|
languages: [],
|
||||||
|
showCreateDialog: false,
|
||||||
|
newCourse: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
languageId: null,
|
||||||
|
difficultyLevel: 1,
|
||||||
|
isPublic: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['user']),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadLanguages() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/api/vocab/languages');
|
||||||
|
this.languages = res.data?.languages || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Konnte Sprachen nicht laden:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadAllCourses() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/api/vocab/courses', { params: { includePublic: true, includeOwn: true } });
|
||||||
|
const courses = res.data || [];
|
||||||
|
// Füge isOwner Flag hinzu
|
||||||
|
this.courses = courses.map(c => ({
|
||||||
|
...c,
|
||||||
|
isOwner: c.ownerUserId === this.user?.id
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Konnte Kurse nicht laden:', e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadMyCourses() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/api/vocab/courses/my');
|
||||||
|
const courses = res.data || [];
|
||||||
|
// Füge isOwner Flag hinzu
|
||||||
|
this.courses = courses.map(c => ({
|
||||||
|
...c,
|
||||||
|
isOwner: c.ownerUserId === this.user?.id
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Konnte meine Kurse nicht laden:', e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async createCourse() {
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/vocab/courses', this.newCourse);
|
||||||
|
this.showCreateDialog = false;
|
||||||
|
this.newCourse = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
languageId: null,
|
||||||
|
difficultyLevel: 1,
|
||||||
|
isPublic: false
|
||||||
|
};
|
||||||
|
await this.loadAllCourses();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Erstellen des Kurses:', e);
|
||||||
|
alert(e.response?.data?.error || 'Fehler beim Erstellen des Kurses');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async enroll(courseId) {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/api/vocab/courses/${courseId}/enroll`);
|
||||||
|
await this.loadAllCourses();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Einschreiben:', e);
|
||||||
|
alert(e.response?.data?.error || 'Fehler beim Einschreiben');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openCourse(courseId) {
|
||||||
|
this.$router.push(`/socialnetwork/vocab/courses/${courseId}`);
|
||||||
|
},
|
||||||
|
editCourse(courseId) {
|
||||||
|
this.$router.push(`/socialnetwork/vocab/courses/${courseId}/edit`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.loadLanguages();
|
||||||
|
await this.loadAllCourses();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vocab-course-list {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
background: #f6f6f6;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin: 10px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item {
|
||||||
|
background: white;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.owner {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.enrolled {
|
||||||
|
background: #2196F3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.public {
|
||||||
|
background: #FF9800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-description {
|
||||||
|
color: #666;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
325
frontend/src/views/social/VocabCourseView.vue
Normal file
325
frontend/src/views/social/VocabCourseView.vue
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vocab-course-view">
|
||||||
|
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||||
|
<div v-else-if="course">
|
||||||
|
<h2>{{ course.title }}</h2>
|
||||||
|
<p v-if="course.description">{{ course.description }}</p>
|
||||||
|
|
||||||
|
<div class="course-info">
|
||||||
|
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
|
||||||
|
<span v-if="course.isPublic">{{ $t('socialnetwork.vocab.courses.public') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isOwner" class="owner-actions">
|
||||||
|
<button @click="showAddLessonDialog = true">{{ $t('socialnetwork.vocab.courses.addLesson') }}</button>
|
||||||
|
<button @click="editCourse">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="course.lessons && course.lessons.length > 0" class="lessons-list">
|
||||||
|
<h3>{{ $t('socialnetwork.vocab.courses.lessons') }}</h3>
|
||||||
|
<div v-for="lesson in course.lessons" :key="lesson.id" class="lesson-item">
|
||||||
|
<div class="lesson-header">
|
||||||
|
<span class="lesson-number">{{ lesson.lessonNumber }}.</span>
|
||||||
|
<h4 @click="openLesson(lesson.id)" class="lesson-title">{{ lesson.title }}</h4>
|
||||||
|
<span v-if="getLessonProgress(lesson.id)?.completed" class="badge completed">
|
||||||
|
{{ $t('socialnetwork.vocab.courses.completed') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="getLessonProgress(lesson.id)?.score" class="score">
|
||||||
|
{{ $t('socialnetwork.vocab.courses.score') }}: {{ getLessonProgress(lesson.id).score }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="lesson.description" class="lesson-description">{{ lesson.description }}</p>
|
||||||
|
<div class="lesson-actions">
|
||||||
|
<button @click="openLesson(lesson.id)">
|
||||||
|
{{ getLessonProgress(lesson.id)?.completed ? $t('socialnetwork.vocab.courses.review') : $t('socialnetwork.vocab.courses.start') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="isOwner" @click="editLesson(lesson.id)">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
|
||||||
|
<button v-if="isOwner" @click="deleteLesson(lesson.id)">{{ $t('general.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p>{{ $t('socialnetwork.vocab.courses.noLessons') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Lesson Dialog -->
|
||||||
|
<div v-if="showAddLessonDialog" class="dialog-overlay" @click="showAddLessonDialog = false">
|
||||||
|
<div class="dialog" @click.stop>
|
||||||
|
<h3>{{ $t('socialnetwork.vocab.courses.addLesson') }}</h3>
|
||||||
|
<form @submit.prevent="addLesson">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.lessonNumber') }}</label>
|
||||||
|
<input type="number" v-model.number="newLesson.lessonNumber" min="1" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.title') }}</label>
|
||||||
|
<input v-model="newLesson.title" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.description') }}</label>
|
||||||
|
<textarea v-model="newLesson.description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.chapter') }}</label>
|
||||||
|
<select v-model="newLesson.chapterId" required>
|
||||||
|
<option value="">{{ $t('socialnetwork.vocab.courses.selectChapter') }}</option>
|
||||||
|
<option v-for="chapter in chapters" :key="chapter.id" :value="chapter.id">{{ chapter.title }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">{{ $t('general.create') }}</button>
|
||||||
|
<button type="button" @click="showAddLessonDialog = false">{{ $t('general.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import apiClient from '@/utils/axios.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'VocabCourseView',
|
||||||
|
props: {
|
||||||
|
courseId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
course: null,
|
||||||
|
progress: [],
|
||||||
|
chapters: [],
|
||||||
|
showAddLessonDialog: false,
|
||||||
|
newLesson: {
|
||||||
|
lessonNumber: 1,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
chapterId: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['user']),
|
||||||
|
isOwner() {
|
||||||
|
return this.course && this.course.ownerUserId === this.user?.id;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
courseId() {
|
||||||
|
this.loadCourse();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadCourse() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/api/vocab/courses/${this.courseId}`);
|
||||||
|
this.course = res.data;
|
||||||
|
await this.loadProgress();
|
||||||
|
if (this.course.languageId) {
|
||||||
|
await this.loadChapters();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Konnte Kurs nicht laden:', e);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadProgress() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/api/vocab/courses/${this.courseId}/progress`);
|
||||||
|
this.progress = res.data || [];
|
||||||
|
} catch (e) {
|
||||||
|
// Nicht eingeschrieben? Progress ist leer
|
||||||
|
this.progress = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadChapters() {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/api/vocab/languages/${this.course.languageId}/chapters`);
|
||||||
|
this.chapters = res.data?.chapters || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Konnte Kapitel nicht laden:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getLessonProgress(lessonId) {
|
||||||
|
return this.progress.find(p => p.lessonId === lessonId);
|
||||||
|
},
|
||||||
|
async addLesson() {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/api/vocab/courses/${this.courseId}/lessons`, this.newLesson);
|
||||||
|
this.showAddLessonDialog = false;
|
||||||
|
this.newLesson = {
|
||||||
|
lessonNumber: 1,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
chapterId: null
|
||||||
|
};
|
||||||
|
await this.loadCourse();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Hinzufügen der Lektion:', e);
|
||||||
|
alert(e.response?.data?.error || 'Fehler beim Hinzufügen der Lektion');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteLesson(lessonId) {
|
||||||
|
if (!confirm(this.$t('socialnetwork.vocab.courses.confirmDelete'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/vocab/lessons/${lessonId}`);
|
||||||
|
await this.loadCourse();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Löschen der Lektion:', e);
|
||||||
|
alert(e.response?.data?.error || 'Fehler beim Löschen der Lektion');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openLesson(lessonId) {
|
||||||
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
|
||||||
|
},
|
||||||
|
editCourse() {
|
||||||
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/edit`);
|
||||||
|
},
|
||||||
|
editLesson(lessonId) {
|
||||||
|
// TODO: Implement edit lesson
|
||||||
|
console.log('Edit lesson', lessonId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.loadCourse();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vocab-course-view {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lessons-list {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-item {
|
||||||
|
background: white;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-number {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-title {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.completed {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-description {
|
||||||
|
color: #666;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
|
<button @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
|
||||||
|
<button @click="goCourses">{{ $t('socialnetwork.vocab.courses.title') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||||
@@ -43,6 +44,9 @@ export default {
|
|||||||
goNewLanguage() {
|
goNewLanguage() {
|
||||||
this.$router.push('/socialnetwork/vocab/new');
|
this.$router.push('/socialnetwork/vocab/new');
|
||||||
},
|
},
|
||||||
|
goCourses() {
|
||||||
|
this.$router.push('/socialnetwork/vocab/courses');
|
||||||
|
},
|
||||||
openLanguage(id) {
|
openLanguage(id) {
|
||||||
this.$router.push(`/socialnetwork/vocab/${id}`);
|
this.$router.push(`/socialnetwork/vocab/${id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user