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

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