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:
Torsten Schulz (local)
2026-01-19 10:58:53 +01:00
parent 9553cc811a
commit b6a4607e60
28 changed files with 3629 additions and 0 deletions

View File

@@ -21,6 +21,37 @@ class VocabController {
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 });
// Courses
this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 });
this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query));
this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId));
this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body));
this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId));
// Lessons
this.addLessonToCourse = this._wrapWithUser((userId, req) => this.service.addLessonToCourse(userId, req.params.courseId, req.body), { successStatus: 201 });
this.updateLesson = this._wrapWithUser((userId, req) => this.service.updateLesson(userId, req.params.lessonId, req.body));
this.deleteLesson = this._wrapWithUser((userId, req) => this.service.deleteLesson(userId, req.params.lessonId));
// Enrollment
this.enrollInCourse = this._wrapWithUser((userId, req) => this.service.enrollInCourse(userId, req.params.courseId), { successStatus: 201 });
this.unenrollFromCourse = this._wrapWithUser((userId, req) => this.service.unenrollFromCourse(userId, req.params.courseId));
this.getMyCourses = this._wrapWithUser((userId) => this.service.getMyCourses(userId));
// Progress
this.getCourseProgress = this._wrapWithUser((userId, req) => this.service.getCourseProgress(userId, req.params.courseId));
this.updateLessonProgress = this._wrapWithUser((userId, req) => this.service.updateLessonProgress(userId, req.params.lessonId, req.body));
// Grammar Exercises
this.getExerciseTypes = this._wrapWithUser((userId) => this.service.getExerciseTypes());
this.createGrammarExercise = this._wrapWithUser((userId, req) => this.service.createGrammarExercise(userId, req.params.lessonId, req.body), { successStatus: 201 });
this.getGrammarExercisesForLesson = this._wrapWithUser((userId, req) => this.service.getGrammarExercisesForLesson(userId, req.params.lessonId));
this.getGrammarExercise = this._wrapWithUser((userId, req) => this.service.getGrammarExercise(userId, req.params.exerciseId));
this.checkGrammarExerciseAnswer = this._wrapWithUser((userId, req) => this.service.checkGrammarExerciseAnswer(userId, req.params.exerciseId, req.body.answer));
this.getGrammarExerciseProgress = this._wrapWithUser((userId, req) => this.service.getGrammarExerciseProgress(userId, req.params.lessonId));
this.updateGrammarExercise = this._wrapWithUser((userId, req) => this.service.updateGrammarExercise(userId, req.params.exerciseId, req.body));
this.deleteGrammarExercise = this._wrapWithUser((userId, req) => this.service.deleteGrammarExercise(userId, req.params.exerciseId));
}
_wrapWithUser(fn, { successStatus = 200 } = {}) {

View 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;
`);
}
};

View File

@@ -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;
`);
}
};

View 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;
`);
}
};

View File

@@ -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;
`);
}
};

View File

@@ -104,6 +104,13 @@ import Weather from './falukant/data/weather.js';
import ProductWeatherEffect from './falukant/type/product_weather_effect.js';
import Blog from './community/blog.js';
import BlogPost from './community/blog_post.js';
import VocabCourse from './community/vocab_course.js';
import VocabCourseLesson from './community/vocab_course_lesson.js';
import VocabCourseEnrollment from './community/vocab_course_enrollment.js';
import VocabCourseProgress from './community/vocab_course_progress.js';
import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js';
import VocabGrammarExercise from './community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js';
import Campaign from './match3/campaign.js';
import Match3Level from './match3/level.js';
import Objective from './match3/objective.js';
@@ -941,5 +948,37 @@ export default function setupAssociations() {
TaxiHighscore.belongsTo(TaxiMap, { foreignKey: 'mapId', as: 'map' });
TaxiMap.hasMany(TaxiHighscore, { foreignKey: 'mapId', as: 'highscores' });
// Vocab Course associations
VocabCourse.belongsTo(User, { foreignKey: 'ownerUserId', as: 'owner' });
User.hasMany(VocabCourse, { foreignKey: 'ownerUserId', as: 'ownedCourses' });
VocabCourseLesson.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' });
VocabCourse.hasMany(VocabCourseLesson, { foreignKey: 'courseId', as: 'lessons' });
VocabCourseEnrollment.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(VocabCourseEnrollment, { foreignKey: 'userId', as: 'courseEnrollments' });
VocabCourseEnrollment.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' });
VocabCourse.hasMany(VocabCourseEnrollment, { foreignKey: 'courseId', as: 'enrollments' });
VocabCourseProgress.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(VocabCourseProgress, { foreignKey: 'userId', as: 'courseProgress' });
VocabCourseProgress.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' });
VocabCourse.hasMany(VocabCourseProgress, { foreignKey: 'courseId', as: 'progress' });
VocabCourseProgress.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' });
VocabCourseLesson.hasMany(VocabCourseProgress, { foreignKey: 'lessonId', as: 'progress' });
// Grammar Exercise associations
VocabGrammarExercise.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' });
VocabCourseLesson.hasMany(VocabGrammarExercise, { foreignKey: 'lessonId', as: 'grammarExercises' });
VocabGrammarExercise.belongsTo(VocabGrammarExerciseType, { foreignKey: 'exerciseTypeId', as: 'exerciseType' });
VocabGrammarExerciseType.hasMany(VocabGrammarExercise, { foreignKey: 'exerciseTypeId', as: 'exercises' });
VocabGrammarExercise.belongsTo(User, { foreignKey: 'createdByUserId', as: 'creator' });
User.hasMany(VocabGrammarExercise, { foreignKey: 'createdByUserId', as: 'createdGrammarExercises' });
VocabGrammarExerciseProgress.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'userId', as: 'grammarExerciseProgress' });
VocabGrammarExerciseProgress.belongsTo(VocabGrammarExercise, { foreignKey: 'exerciseId', as: 'exercise' });
VocabGrammarExercise.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'exerciseId', as: 'progress' });
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -129,6 +129,15 @@ import ChatRight from './chat/rights.js';
import ChatUserRight from './chat/user_rights.js';
import RoomType from './chat/room_type.js';
// — Vocab Courses —
import VocabCourse from './community/vocab_course.js';
import VocabCourseLesson from './community/vocab_course_lesson.js';
import VocabCourseEnrollment from './community/vocab_course_enrollment.js';
import VocabCourseProgress from './community/vocab_course_progress.js';
import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js';
import VocabGrammarExercise from './community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js';
const models = {
SettingsType,
UserParamValue,
@@ -263,6 +272,15 @@ const models = {
TaxiMapTileStreet,
TaxiMapTileHouse,
TaxiHighscore,
// Vocab Courses
VocabCourse,
VocabCourseLesson,
VocabCourseEnrollment,
VocabCourseProgress,
VocabGrammarExerciseType,
VocabGrammarExercise,
VocabGrammarExerciseProgress,
};
export default models;

View File

@@ -22,6 +22,37 @@ router.get('/chapters/:chapterId', vocabController.getChapter);
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
// Courses
router.post('/courses', vocabController.createCourse);
router.get('/courses', vocabController.getCourses);
router.get('/courses/my', vocabController.getMyCourses);
router.get('/courses/:courseId', vocabController.getCourse);
router.put('/courses/:courseId', vocabController.updateCourse);
router.delete('/courses/:courseId', vocabController.deleteCourse);
// Lessons
router.post('/courses/:courseId/lessons', vocabController.addLessonToCourse);
router.put('/lessons/:lessonId', vocabController.updateLesson);
router.delete('/lessons/:lessonId', vocabController.deleteLesson);
// Enrollment
router.post('/courses/:courseId/enroll', vocabController.enrollInCourse);
router.delete('/courses/:courseId/enroll', vocabController.unenrollFromCourse);
// Progress
router.get('/courses/:courseId/progress', vocabController.getCourseProgress);
router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress);
// Grammar Exercises
router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise);
router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson);
router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress);
router.get('/grammar-exercises/:exerciseId', vocabController.getGrammarExercise);
router.post('/grammar-exercises/:exerciseId/check', vocabController.checkGrammarExerciseAnswer);
router.put('/grammar-exercises/:exerciseId', vocabController.updateGrammarExercise);
router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExercise);
export default router;

View 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);
});

View File

@@ -1,7 +1,15 @@
import crypto from 'crypto';
import User from '../models/community/user.js';
import VocabCourse from '../models/community/vocab_course.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import VocabCourseEnrollment from '../models/community/vocab_course_enrollment.js';
import VocabCourseProgress from '../models/community/vocab_course_progress.js';
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
import { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js';
import { Op } from 'sequelize';
export default class VocabService {
async _getUserByHashedId(hashedUserId) {
@@ -527,6 +535,679 @@ export default class VocabService {
return { created: Boolean(mapping?.id) };
});
}
// ========== COURSE METHODS ==========
async createCourse(hashedUserId, { title, description, languageId, difficultyLevel = 1, isPublic = false }) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Zugriff auf Sprache
await this._getLanguageAccess(user.id, languageId);
const shareCode = isPublic ? crypto.randomBytes(8).toString('hex') : null;
const course = await VocabCourse.create({
ownerUserId: user.id,
title,
description,
languageId: Number(languageId),
difficultyLevel: Number(difficultyLevel) || 1,
isPublic: Boolean(isPublic),
shareCode
});
return course.get({ plain: true });
}
async getCourses(hashedUserId, { includePublic = true, includeOwn = true } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const where = {};
if (includeOwn && includePublic) {
where[Op.or] = [
{ ownerUserId: user.id },
{ isPublic: true }
];
} else if (includeOwn) {
where.ownerUserId = user.id;
} else if (includePublic) {
where.isPublic = true;
}
const courses = await VocabCourse.findAll({
where,
order: [['createdAt', 'DESC']]
});
return courses.map(c => c.get({ plain: true }));
}
async getCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId, {
include: [
{
model: VocabCourseLesson,
as: 'lessons',
order: [['lessonNumber', 'ASC']]
}
]
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const courseData = course.get({ plain: true });
courseData.lessons = courseData.lessons || [];
// Sortiere Lektionen nach Woche, Tag, dann Nummer
courseData.lessons.sort((a, b) => {
if (a.weekNumber !== b.weekNumber) {
return (a.weekNumber || 999) - (b.weekNumber || 999);
}
if (a.dayNumber !== b.dayNumber) {
return (a.dayNumber || 999) - (b.dayNumber || 999);
}
return a.lessonNumber - b.lessonNumber;
});
return courseData;
}
async updateCourse(hashedUserId, courseId, { title, description, difficultyLevel, isPublic }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update the course');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel);
if (isPublic !== undefined) {
updates.isPublic = Boolean(isPublic);
// Generiere Share-Code wenn Kurs öffentlich wird
if (isPublic && !course.shareCode) {
updates.shareCode = crypto.randomBytes(8).toString('hex');
} else if (!isPublic) {
updates.shareCode = null;
}
}
await course.update(updates);
return course.get({ plain: true });
}
async deleteCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete the course');
err.status = 403;
throw err;
}
await course.destroy();
return { success: true };
}
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can add lessons');
err.status = 403;
throw err;
}
// Prüfe, ob Kapitel zur gleichen Sprache gehört (nur wenn chapterId angegeben)
if (chapterId) {
const [chapter] = await sequelize.query(
`SELECT language_id FROM community.vocab_chapter WHERE id = :chapterId`,
{
replacements: { chapterId: Number(chapterId) },
type: sequelize.QueryTypes.SELECT
}
);
if (!chapter || chapter.language_id !== course.languageId) {
const err = new Error('Chapter does not belong to the course language');
err.status = 400;
throw err;
}
}
const lesson = await VocabCourseLesson.create({
courseId: course.id,
chapterId: chapterId ? Number(chapterId) : null,
lessonNumber: Number(lessonNumber),
title,
description,
weekNumber: weekNumber ? Number(weekNumber) : null,
dayNumber: dayNumber ? Number(dayNumber) : null,
lessonType: lessonType || 'vocab',
audioUrl: audioUrl || null,
culturalNotes: culturalNotes || null,
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
});
return lesson.get({ plain: true });
}
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update lessons');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (lessonNumber !== undefined) updates.lessonNumber = Number(lessonNumber);
if (weekNumber !== undefined) updates.weekNumber = weekNumber ? Number(weekNumber) : null;
if (dayNumber !== undefined) updates.dayNumber = dayNumber ? Number(dayNumber) : null;
if (lessonType !== undefined) updates.lessonType = lessonType;
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
await lesson.update(updates);
return lesson.get({ plain: true });
}
async deleteLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete lessons');
err.status = 403;
throw err;
}
await lesson.destroy();
return { success: true };
}
async enrollInCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Course is not public');
err.status = 403;
throw err;
}
const [enrollment, created] = await VocabCourseEnrollment.findOrCreate({
where: { userId: user.id, courseId: course.id },
defaults: { userId: user.id, courseId: course.id }
});
if (!created) {
const err = new Error('Already enrolled in this course');
err.status = 400;
throw err;
}
return enrollment.get({ plain: true });
}
async unenrollFromCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 404;
throw err;
}
await enrollment.destroy();
return { success: true };
}
async getMyCourses(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [{ model: VocabCourse, as: 'course' }],
order: [['enrolledAt', 'DESC']]
});
return enrollments.map(e => ({
...e.course.get({ plain: true }),
enrolledAt: e.enrolledAt
}));
}
async getCourseProgress(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const progress = await VocabCourseProgress.findAll({
where: { userId: user.id, courseId: Number(courseId) },
include: [{ model: VocabCourseLesson, as: 'lesson' }],
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']]
});
return progress.map(p => p.get({ plain: true }));
}
async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const lessonData = await VocabCourseLesson.findByPk(lesson.id);
const targetScore = lessonData.targetScorePercent || 80;
const actualScore = Number(score) || 0;
const hasReachedTarget = actualScore >= targetScore;
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
const [progress, created] = await VocabCourseProgress.findOrCreate({
where: { userId: user.id, lessonId: lesson.id },
defaults: {
userId: user.id,
courseId: lesson.courseId,
lessonId: lesson.id,
completed: isCompleted,
score: actualScore,
lastAccessedAt: new Date()
}
});
if (!created) {
const updates = { lastAccessedAt: new Date() };
if (score !== undefined) {
updates.score = Math.max(progress.score, actualScore);
// Prüfe, ob Ziel jetzt erreicht wurde
if (updates.score >= targetScore && !progress.completed) {
if (!lessonData.requiresReview) {
updates.completed = true;
updates.completedAt = new Date();
}
}
}
if (completed !== undefined) {
updates.completed = Boolean(completed);
if (completed && !progress.completedAt) {
updates.completedAt = new Date();
}
}
await progress.update(updates);
} else if (isCompleted) {
progress.completed = true;
progress.completedAt = new Date();
await progress.save();
}
const progressData = progress.get({ plain: true });
progressData.targetScore = targetScore;
progressData.hasReachedTarget = progressData.score >= targetScore;
progressData.needsReview = lessonData.requiresReview && !progressData.hasReachedTarget;
return progressData;
}
// ========== GRAMMAR EXERCISE METHODS ==========
async getExerciseTypes() {
const types = await VocabGrammarExerciseType.findAll({
order: [['name', 'ASC']]
});
return types.map(t => t.get({ plain: true }));
}
async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe, ob User Besitzer des Kurses ist
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can add grammar exercises');
err.status = 403;
throw err;
}
const exercise = await VocabGrammarExercise.create({
lessonId: lesson.id,
exerciseTypeId: Number(exerciseTypeId),
exerciseNumber: Number(exerciseNumber),
title,
instruction,
questionData,
answerData,
explanation,
createdByUserId: user.id
});
return exercise.get({ plain: true });
}
async getGrammarExercisesForLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: lesson.id },
include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }],
order: [['exerciseNumber', 'ASC']]
});
return exercises.map(e => e.get({ plain: true }));
}
async getGrammarExercise(hashedUserId, exerciseId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] },
{ model: VocabGrammarExerciseType, as: 'exerciseType' }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
return exercise.get({ plain: true });
}
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: exercise.lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
// Überprüfe Antwort (vereinfachte Logik - kann je nach Übungstyp erweitert werden)
const isCorrect = this._checkAnswer(exercise.answerData, userAnswer, exercise.exerciseTypeId);
// Speichere Fortschritt
const [progress, created] = await VocabGrammarExerciseProgress.findOrCreate({
where: { userId: user.id, exerciseId: exercise.id },
defaults: {
userId: user.id,
exerciseId: exercise.id,
attempts: 1,
correctAttempts: isCorrect ? 1 : 0,
lastAttemptAt: new Date(),
completed: false
}
});
if (!created) {
progress.attempts += 1;
if (isCorrect) {
progress.correctAttempts += 1;
if (!progress.completed) {
progress.completed = true;
progress.completedAt = new Date();
}
}
progress.lastAttemptAt = new Date();
await progress.save();
} else if (isCorrect) {
progress.completed = true;
progress.completedAt = new Date();
await progress.save();
}
return {
correct: isCorrect,
explanation: exercise.explanation,
progress: progress.get({ plain: true })
};
}
_checkAnswer(answerData, userAnswer, exerciseTypeId) {
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
if (!answerData || !userAnswer) return false;
// Für Multiple Choice: Prüfe ob userAnswer eine der richtigen Antworten ist
if (exerciseTypeId === 2) { // multiple_choice
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
return correctAnswers.includes(userAnswer);
}
// Für Lückentext: Normalisiere und vergleiche
if (exerciseTypeId === 1) { // gap_fill
const normalize = (str) => String(str || '').trim().toLowerCase();
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
const normalizedUserAnswer = normalize(userAnswer);
return correctAnswers.some(correct => normalize(correct) === normalizedUserAnswer);
}
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
const normalize = (str) => String(str || '').trim().toLowerCase();
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
return correctAnswers.some(correct => normalize(correct) === normalize(userAnswer));
}
async getGrammarExerciseProgress(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
const exerciseIds = exercises.map(e => e.id);
const progress = await VocabGrammarExerciseProgress.findAll({
where: {
userId: user.id,
exerciseId: { [Op.in]: exerciseIds }
}
});
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
return exercises.map(exercise => ({
...exercise,
progress: progressMap.get(exercise.id) || null
}));
}
async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (exercise.lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update exercises');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (instruction !== undefined) updates.instruction = instruction;
if (questionData !== undefined) updates.questionData = questionData;
if (answerData !== undefined) updates.answerData = answerData;
if (explanation !== undefined) updates.explanation = explanation;
if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber);
await exercise.update(updates);
return exercise.get({ plain: true });
}
async deleteGrammarExercise(hashedUserId, exerciseId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (exercise.lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete exercises');
err.status = 403;
throw err;
}
await exercise.destroy();
return { success: true };
}
}

View 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.

View 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!
-- ============================================

View File

@@ -144,8 +144,201 @@ const syncDatabase = async () => {
ON community.vocab_chapter_lexeme(learning_lexeme_id);
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
ON community.vocab_chapter_lexeme(reference_lexeme_id);
// Kurs-Tabellen
CREATE TABLE IF NOT EXISTS community.vocab_course (
id SERIAL PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
language_id INTEGER NOT NULL,
difficulty_level INTEGER DEFAULT 1,
is_public BOOLEAN DEFAULT false,
share_code TEXT,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_course_owner_fk
FOREIGN KEY (owner_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_share_code_uniq UNIQUE (share_code)
);
CREATE TABLE IF NOT EXISTS community.vocab_course_lesson (
id SERIAL PRIMARY KEY,
course_id INTEGER NOT NULL,
chapter_id INTEGER,
lesson_number INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
week_number INTEGER,
day_number INTEGER,
lesson_type TEXT DEFAULT 'vocab',
audio_url TEXT,
cultural_notes TEXT,
target_minutes INTEGER,
target_score_percent INTEGER DEFAULT 80,
requires_review BOOLEAN DEFAULT false,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_course_lesson_course_fk
FOREIGN KEY (course_id)
REFERENCES community.vocab_course(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_lesson_chapter_fk
FOREIGN KEY (chapter_id)
REFERENCES community.vocab_chapter(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_lesson_unique UNIQUE (course_id, lesson_number)
);
CREATE TABLE IF NOT EXISTS community.vocab_course_enrollment (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
enrolled_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_course_enrollment_user_fk
FOREIGN KEY (user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_enrollment_course_fk
FOREIGN KEY (course_id)
REFERENCES community.vocab_course(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_enrollment_unique UNIQUE (user_id, course_id)
);
CREATE TABLE IF NOT EXISTS community.vocab_course_progress (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
lesson_id INTEGER NOT NULL,
completed BOOLEAN DEFAULT false,
score INTEGER DEFAULT 0,
last_accessed_at TIMESTAMP WITHOUT TIME ZONE,
completed_at TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT vocab_course_progress_user_fk
FOREIGN KEY (user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_progress_course_fk
FOREIGN KEY (course_id)
REFERENCES community.vocab_course(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_progress_lesson_fk
FOREIGN KEY (lesson_id)
REFERENCES community.vocab_course_lesson(id)
ON DELETE CASCADE,
CONSTRAINT vocab_course_progress_unique UNIQUE (user_id, lesson_id)
);
CREATE INDEX IF NOT EXISTS vocab_course_owner_idx
ON community.vocab_course(owner_user_id);
CREATE INDEX IF NOT EXISTS vocab_course_language_idx
ON community.vocab_course(language_id);
CREATE INDEX IF NOT EXISTS vocab_course_public_idx
ON community.vocab_course(is_public);
CREATE INDEX IF NOT EXISTS vocab_course_lesson_course_idx
ON community.vocab_course_lesson(course_id);
CREATE INDEX IF NOT EXISTS vocab_course_lesson_chapter_idx
ON community.vocab_course_lesson(chapter_id);
CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx
ON community.vocab_course_lesson(course_id, week_number);
CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx
ON community.vocab_course_lesson(lesson_type);
CREATE INDEX IF NOT EXISTS vocab_course_enrollment_user_idx
ON community.vocab_course_enrollment(user_id);
CREATE INDEX IF NOT EXISTS vocab_course_enrollment_course_idx
ON community.vocab_course_enrollment(course_id);
CREATE INDEX IF NOT EXISTS vocab_course_progress_user_idx
ON community.vocab_course_progress(user_id);
CREATE INDEX IF NOT EXISTS vocab_course_progress_course_idx
ON community.vocab_course_progress(course_id);
CREATE INDEX IF NOT EXISTS vocab_course_progress_lesson_idx
ON community.vocab_course_progress(lesson_id);
// Grammatik-Übungstypen
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
);
// Grammatik-Übungen
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise (
id SERIAL PRIMARY KEY,
lesson_id INTEGER NOT NULL,
exercise_type_id INTEGER NOT NULL,
exercise_number INTEGER NOT NULL,
title TEXT NOT NULL,
instruction TEXT,
question_data JSONB NOT NULL,
answer_data JSONB NOT NULL,
explanation TEXT,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_grammar_exercise_lesson_fk
FOREIGN KEY (lesson_id)
REFERENCES community.vocab_course_lesson(id)
ON DELETE CASCADE,
CONSTRAINT vocab_grammar_exercise_type_fk
FOREIGN KEY (exercise_type_id)
REFERENCES community.vocab_grammar_exercise_type(id)
ON DELETE CASCADE,
CONSTRAINT vocab_grammar_exercise_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number)
);
// Fortschritt für Grammatik-Übungen
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
exercise_id INTEGER NOT NULL,
attempts INTEGER DEFAULT 0,
correct_attempts INTEGER DEFAULT 0,
last_attempt_at TIMESTAMP WITHOUT TIME ZONE,
completed BOOLEAN DEFAULT false,
completed_at TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT vocab_grammar_exercise_progress_user_fk
FOREIGN KEY (user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_grammar_exercise_progress_exercise_fk
FOREIGN KEY (exercise_id)
REFERENCES community.vocab_grammar_exercise(id)
ON DELETE CASCADE,
CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id)
);
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx
ON community.vocab_grammar_exercise(lesson_id);
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx
ON community.vocab_grammar_exercise(exercise_type_id);
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx
ON community.vocab_grammar_exercise_progress(user_id);
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
ON community.vocab_grammar_exercise_progress(exercise_id);
-- Standard-Übungstypen einfügen
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
('gap_fill', 'Lückentext-Übung'),
('multiple_choice', 'Multiple-Choice-Fragen'),
('sentence_building', 'Satzbau-Übung'),
('transformation', 'Satzumformung'),
('conjugation', 'Konjugations-Übung'),
('declension', 'Deklinations-Übung')
ON CONFLICT (name) DO NOTHING;
`);
console.log("✅ Vocab-Trainer Tabellen sind vorhanden.");
console.log("✅ Vocab-Course Tabellen sind vorhanden.");
console.log("✅ Vocab-Grammar-Exercise Tabellen sind vorhanden.");
} catch (e) {
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e);
}