chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
@@ -0,0 +1,21 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const AssessmentSession = sequelize.define("AssessmentSession", {
session_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
user_id: { type: DataTypes.BIGINT, allowNull: false },
assessment_id: { type: DataTypes.BIGINT, allowNull: false },
course_id: { type: DataTypes.BIGINT, allowNull: true },
started_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
expires_at: { type: DataTypes.DATE, allowNull: true }, // null = no time limit
status: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'in_progress' }, // 'in_progress' | 'completed' | 'expired'
attempt_id: { type: DataTypes.BIGINT, allowNull: true }, // FK → quiz_attempts once graded/expired
draft_answers: { type: DataTypes.JSONB, allowNull: true, defaultValue: null },
last_heartbeat_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
}, {
tableName: "assessment_sessions",
timestamps: true,
});
module.exports = AssessmentSession;
@@ -0,0 +1,6 @@
'use strict';
const excludeAttributes = ['description'];
const jsonbSchemas = {};
module.exports = { excludeAttributes, jsonbSchemas };
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Category = sequelize.define('Category', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true },
name: { type: DataTypes.STRING(100), allowNull: false, unique: true },
slug: { type: DataTypes.STRING(120), allowNull: false, unique: true },
description: { type: DataTypes.TEXT, allowNull: true },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Status" },
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
}, {
tableName: 'categories',
timestamps: true,
paranoid: true,
});
module.exports = mdl_Category;
@@ -0,0 +1,27 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const Certificate = sequelize.define("Certificate", {
certificate_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, allowNull: false, defaultValue: DataTypes.UUIDV4, unique: true },
cert_no: { type: DataTypes.STRING(25), allowNull: false, unique: true },
ref_no: { type: DataTypes.STRING(50), allowNull: false },
user_id: { type: DataTypes.BIGINT, allowNull: false },
course_id: { type: DataTypes.BIGINT, allowNull: false },
instructors: { type: DataTypes.TEXT, allowNull: true },
score: { type: DataTypes.INTEGER, allowNull: true },
length_str: { type: DataTypes.STRING(50), allowNull: true },
issued_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
}, {
tableName: "certificates",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ unique: true, fields: ["user_id", "course_id"] },
{ fields: ["uuid"] },
{ fields: ["cert_no"] },
],
});
module.exports = Certificate;
@@ -0,0 +1,60 @@
/***********************************************************************************************************************************************************************
* File Name: completion_requirement.mdl.js
* Type of Program: Model
* Description: Admin-configured rule describing what "complete" means for a course, unit, or lesson.
*
* CompletionRequirement — polymorphic via (entity_type, entity_id); entity_id is the BIGINT PK of the
* owning courses/units/lessons row (not a UUID reference like TaskRequirement —
* this row is OWNED by the entity, not pointing at an arbitrary target).
* type dispatches through utils/courses/completion_requirements.registry.js.
* Multiple rows on one entity are AND'd together by services/courses/completion.service.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const CompletionRequirement = sequelize.define('CompletionRequirement', {
requirement_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
entity_type: {
type: DataTypes.ENUM('course', 'unit', 'lesson'),
allowNull: false,
filterable: true,
},
entity_id: {
type: DataTypes.BIGINT,
allowNull: false,
comment: 'course_id | unit_id | lesson_id depending on entity_type. No DB-level FK (polymorphic across 3 tables).',
filterable: true,
},
type: {
type: DataTypes.ENUM('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete', 'watch_video', 'listen_audio'),
allowNull: false,
filterable: true,
},
// ── watch_percent ───────────────────────────────────────────────────────
min_percent: { type: DataTypes.INTEGER, allowNull: true, comment: 'watch_percent only — minimum % of media watched.', filterable: false },
// ── manual_complete ─────────────────────────────────────────────────────
button_label: { type: DataTypes.STRING, allowNull: true, comment: 'manual_complete only — optional custom CTA text.', filterable: false },
is_required: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, filterable: true },
order: { type: DataTypes.INTEGER, defaultValue: 0, filterable: true },
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'completion_requirements',
paranoid: true,
timestamps: true,
});
module.exports = CompletionRequirement;
@@ -0,0 +1,84 @@
/***********************************************************************************************************************************************************************
* File Name: completion_requirement_progress.mdl.js
* Type of Program: Model
* Description: Per-user tracking state for a CompletionRequirement, for the requirement types that need
* genuinely new storage (watch_percent's running progress_percent, watch_video/listen_audio's
* per-block progress_percent map, and manual_complete's completed flag — pass_quiz reads
* QuizAttempt.passed directly, and read_all_content reads CourseReadingProgress directly;
* neither needs a row here).
*
* CompletionRequirementProgress — UPSERT key: (requirement_id, user_id).
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const CompletionRequirementProgress = sequelize.define('CompletionRequirementProgress', {
progress_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
requirement_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'completion_requirements', key: 'requirement_id' },
onDelete: 'CASCADE',
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
entity_type: {
type: DataTypes.ENUM('course', 'unit', 'lesson'),
allowNull: false,
comment: 'Denormalized from the requirement row to avoid a join on every read.',
},
entity_id: {
type: DataTypes.BIGINT,
allowNull: false,
comment: 'Denormalized from the requirement row.',
},
progress_percent: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'watch_percent only — running max % watched.',
},
block_progress: {
type: DataTypes.JSONB,
allowNull: true,
comment: 'watch_percent/watch_video/listen_audio — { [block_id]: { percent, updatedAt } } running max + last-sample timestamp per block. The timestamp lets recordWatchProgress validate a reported percent against real elapsed wall-clock time (anti-skip). watch_video/listen_audio: completed once every current block of that type is at 100. watch_percent: also keeps progress_percent as its aggregate max across whichever block reports.',
},
completed: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
},
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'completion_requirement_progress',
timestamps: true,
paranoid: false, // progress rows are never soft-deleted, matches CourseReadingProgress
indexes: [
{
unique: true,
fields: ['requirement_id', 'user_id'],
name: 'uq_crp2_requirement_user',
},
{ fields: ['user_id'], name: 'idx_crp2_user_id' },
{ fields: ['entity_type', 'entity_id'], name: 'idx_crp2_entity_type_entity_id' },
],
});
module.exports = CompletionRequirementProgress;
@@ -0,0 +1,16 @@
'use strict';
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const CourseAchievement = sequelize.define('CourseAchievement', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
achievement_key: { type: DataTypes.STRING(100), allowNull: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: 'course_achievements',
timestamps: true,
});
module.exports = CourseAchievement;
@@ -0,0 +1,25 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CourseAssessment = sequelize.define("CourseAssessment", {
assessment_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
course_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one per course
title: { type: DataTypes.STRING(255), allowNull: true },
is_required: { type: DataTypes.BOOLEAN, defaultValue: false },
passing_score: { type: DataTypes.INTEGER, defaultValue: 70 },
time_limit_minutes: { type: DataTypes.INTEGER, allowNull: true }, // null = no limit
max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all
max_attempts: { type: DataTypes.INTEGER, defaultValue: 3 }, // failed attempts before cooldown
cooldown_hours: { type: DataTypes.INTEGER, defaultValue: 24 }, // hours locked after hitting max_attempts
shuffle_questions: { type: DataTypes.BOOLEAN, defaultValue: false },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "course_assessments",
timestamps: true,
paranoid: true,
});
module.exports = CourseAssessment
@@ -0,0 +1,21 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CourseInstructor = sequelize.define("CourseInstructor", {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
user_id: { type: DataTypes.BIGINT, allowNull: true },
display_name: { type: DataTypes.STRING(255), allowNull: false },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
created_by: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "course_instructors",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ fields: ["course_id"] },
],
});
module.exports = CourseInstructor;
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CourseObjective = sequelize.define("CourseObjective", {
objective_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
text: { type: DataTypes.TEXT, allowNull: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "course_objectives",
timestamps: true,
paranoid: true,
});
module.exports = CourseObjective
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CoursePrerequisite = sequelize.define("CoursePrerequisite", {
prereq_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false }, // the course that HAS this prereq
ref_type: { type: DataTypes.ENUM("course", "unit", "lesson"), allowNull: false },
ref_id: { type: DataTypes.BIGINT, allowNull: false }, // FK to courses/units/lessons
order_index:{ type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "course_prerequisites",
timestamps: true,
});
module.exports = CoursePrerequisite
@@ -0,0 +1,26 @@
'use strict';
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Product = require('./products.mdl');
const mdl_CoursePurchase = sequelize.define('CoursePurchase', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: DataTypes.BIGINT, allowNull: false },
product_id: { type: DataTypes.BIGINT, allowNull: false },
amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
currency: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'USD' },
status: { type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'refunded'), allowNull: false, defaultValue: 'pending' },
provider: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'paypal' },
provider_payload: { type: DataTypes.JSONB, allowNull: true, defaultValue: {} },
expires_at: { type: DataTypes.DATE, allowNull: true },
paid_at: { type: DataTypes.DATE, allowNull: true },
}, {
tableName: 'course_purchases',
timestamps: true,
paranoid: true,
});
mdl_CoursePurchase.belongsTo(mdl_Product, { foreignKey: 'product_id', as: 'product' });
mdl_Product.hasMany(mdl_CoursePurchase, { foreignKey: 'product_id', as: 'purchases' });
module.exports = mdl_CoursePurchase;
@@ -0,0 +1,82 @@
/***********************************************************************************************************************************************************************
* File Name: course_reading_progress.mdl.js
* Type of Program: Model
* Description: Tracks a user's reading progress through a course hierarchy (course → unit → lesson).
*
* CourseReadingProgress — one row per (user, type, reference).
* UPSERT key: (user_id, type, reference_id)
* reference_id points to course.uuid | unit.uuid | lesson.uuid based on type.
* status flips in_progress → completed when the user finishes the item.
* last_accessed_at updated on every UPSERT.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const CourseReadingProgress = sequelize.define('CourseReadingProgress', {
progress_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
course_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'courses', key: 'course_id' },
onDelete: 'CASCADE',
},
reference_id: {
type: DataTypes.UUID,
allowNull: false,
comment: 'uuid of the course | unit | lesson depending on type.',
},
type: {
type: DataTypes.ENUM('course', 'unit', 'lesson'),
allowNull: false,
},
status: {
type: DataTypes.ENUM('in_progress', 'completed'),
allowNull: false,
defaultValue: 'in_progress',
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Set when status flips to completed. Null while in_progress.',
},
last_accessed_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: 'Updated on every UPSERT.',
},
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'course_reading_progress',
timestamps: true,
paranoid: false, // progress rows are never soft-deleted
indexes: [
{
unique: true,
fields: ['user_id', 'type', 'reference_id'],
name: 'uq_crp_user_type_reference',
},
{ fields: ['user_id'], name: 'idx_crp_user_id' },
{ fields: ['course_id'], name: 'idx_crp_course_id' },
{ fields: ['type'], name: 'idx_crp_type' },
{ fields: ['status'], name: 'idx_crp_status' },
],
});
module.exports = CourseReadingProgress;
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CourseRole = sequelize.define("CourseRole", {
role_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
text: { type: DataTypes.STRING(255), allowNull: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "course_roles",
timestamps: true,
paranoid: true,
});
module.exports = CourseRole
@@ -0,0 +1,32 @@
/***********************************************************************************************************************************************************************
* File Name: course_units.mdl.js
* Type of Program: Model (junction)
* Description: Attaches a standalone Unit to a Course. A Unit can live in many
* Courses; per-course ordering lives here (order_index), not on the
* Unit itself. Detaching removes the row — the Unit survives.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 7, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CourseUnit = sequelize.define("CourseUnit", {
course_unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
unit_id: { type: DataTypes.BIGINT, allowNull: false },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "course_units",
timestamps: true,
indexes: [
{ unique: true, fields: ["course_id", "unit_id"], name: "uq_course_units_course_unit" },
{ fields: ["course_id"], name: "idx_course_units_course_id" },
{ fields: ["unit_id"], name: "idx_course_units_unit_id" },
{ fields: ["order_index"], name: "idx_course_units_order" },
],
});
module.exports = CourseUnit;
@@ -0,0 +1,154 @@
// models/courses/associations.js
const { Course, CourseProductCategory } = require("./courses.mdl");
const Unit = require("./units.mdl");
const Lesson = require("./lessons.mdl");
const CourseUnit = require("./course_units.mdl");
const UnitLesson = require("./unit_lessons.mdl");
const LessonPage = require("./lesson_page.mdl");
const CourseObjective = require("./course_objective.mdl");
const LessonObjective = require("./lesson_objective.mdl");
const CoursePrerequisite = require("./course_prerequisite.mdl");
const CourseRole = require("./course_role.mdl");
const CourseAssessment = require("./course_assessment.mdl");
const UnitQuiz = require("./unit_quiz.mdl");
const QuizQuestion = require("./quiz_question.mdl");
const QuizOption = require("./quiz_option.mdl");
const mdl_Users = require("../users/users.mdl");
const QuizAttempt = require("./quiz_attempt.mdl");
const AssessmentSession = require("./assessment_session.mdl");
const QuizSession = require("./quiz_session.mdl");
const mdl_Category = require("./categories.mdl");
const Certificate = require("./certificate.mdl");
const CourseInstructor = require("./course_instructor.mdl");
const CourseReadingProgress = require("./course_reading_progress.mdl");
const UnitReadingProgress = require("./unit_reading_progress.mdl");
const LessonReadingProgress = require("./lesson_reading_progress.mdl");
const CourseAchievement = require("./course_achievement.mdl");
const CompletionRequirement = require("./completion_requirement.mdl");
const CompletionRequirementProgress = require("./completion_requirement_progress.mdl");
// ── CourseReadingProgress ─────────────────────────────────────────────────────
CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
CourseReadingProgress.belongsTo(Course, { foreignKey: 'course_id', as: 'course' });
mdl_Users.hasMany(CourseReadingProgress, { foreignKey: 'user_id', as: 'courseReadingProgress' });
Course.hasMany(CourseReadingProgress, { foreignKey: 'course_id', as: 'readingProgress' });
// ── UnitReadingProgress ───────────────────────────────────────────────────────
UnitReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
UnitReadingProgress.belongsTo(Course, { foreignKey: 'course_id', as: 'course' });
UnitReadingProgress.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' });
mdl_Users.hasMany(UnitReadingProgress, { foreignKey: 'user_id', as: 'unitReadingProgress' });
Unit.hasMany(UnitReadingProgress, { foreignKey: 'unit_id', as: 'readingProgress' });
// ── LessonReadingProgress ─────────────────────────────────────────────────────
LessonReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
LessonReadingProgress.belongsTo(Course, { foreignKey: 'course_id', as: 'course' });
LessonReadingProgress.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' });
LessonReadingProgress.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' });
mdl_Users.hasMany(LessonReadingProgress, { foreignKey: 'user_id', as: 'lessonReadingProgress' });
Lesson.hasMany(LessonReadingProgress, { foreignKey: 'lesson_id', as: 'readingProgress' });
// ── CompletionRequirement ─────────────────────────────────────────────────────
// Polymorphic across course/unit/lesson via (entity_type, entity_id) — no belongsTo
// to Course/Unit/Lesson here (can't express a 3-way FK); entity resolution happens
// in services/courses/completion.service.js.
CompletionRequirement.hasMany(CompletionRequirementProgress, { foreignKey: 'requirement_id', as: 'progress' });
CompletionRequirementProgress.belongsTo(CompletionRequirement, { foreignKey: 'requirement_id', as: 'requirement' });
CompletionRequirementProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
// ── Course ⇄ Unit / Unit ⇄ Lesson (junctions) ─────────────────────────────────
// Units and Lessons are standalone entities. Membership + ordering live on the
// course_units / unit_lessons junction rows (order_index).
Course.belongsToMany(Unit, { through: CourseUnit, foreignKey: "course_id", otherKey: "unit_id", as: "units" });
Unit.belongsToMany(Course, { through: CourseUnit, foreignKey: "unit_id", otherKey: "course_id", as: "courses" });
Unit.belongsToMany(Lesson, { through: UnitLesson, foreignKey: "unit_id", otherKey: "lesson_id", as: "lessons" });
Lesson.belongsToMany(Unit, { through: UnitLesson, foreignKey: "lesson_id", otherKey: "unit_id", as: "units" });
// Direct junction access (attach / detach / reorder / count queries)
Course.hasMany(CourseUnit, { as: "unitLinks", foreignKey: "course_id" });
CourseUnit.belongsTo(Course, { as: "course", foreignKey: "course_id" });
CourseUnit.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" });
Unit.hasMany(CourseUnit, { as: "courseLinks", foreignKey: "unit_id" });
Unit.hasMany(UnitLesson, { as: "lessonLinks", foreignKey: "unit_id" });
UnitLesson.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" });
UnitLesson.belongsTo(Lesson, { as: "lesson", foreignKey: "lesson_id" });
Lesson.hasMany(UnitLesson, { as: "unitLinks", foreignKey: "lesson_id" });
// ── Course ────────────────────────────────────────────────────────────────────
Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Course.hasMany(CourseObjective, { as: "objectives", foreignKey: "course_id" });
Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" });
Course.hasMany(CourseRole, { as: "roles", foreignKey: "course_id" });
Course.hasOne(CourseAssessment, { as: "assessment", foreignKey: "course_id" });
Course.hasMany(Certificate, { as: "certificates", foreignKey: "course_id" });
Course.hasMany(CourseInstructor, { as: "instructors", foreignKey: "course_id" });
CourseInstructor.belongsTo(Course, { as: "course", foreignKey: "course_id" });
CourseInstructor.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" });
Course.hasMany(CourseAchievement, { as: "courseAchievements", foreignKey: "course_id" });
CourseAchievement.belongsTo(Course, { as: "course", foreignKey: "course_id" });
Course.belongsToMany(mdl_Category, { through: CourseProductCategory, foreignKey: 'course_id', otherKey: 'category_id', as: 'categories' });
mdl_Category.belongsToMany(Course, { through: CourseProductCategory, foreignKey: 'category_id', otherKey: 'course_id', as: 'courses' });
// ── Unit ──────────────────────────────────────────────────────────────────────
Unit.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Unit.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Unit.hasOne(UnitQuiz, { as: "quiz", foreignKey: "unit_id" });
// ── Lesson ────────────────────────────────────────────────────────────────────
Lesson.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Lesson.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Lesson.hasOne(LessonPage, { as: "page", foreignKey: "lesson_id" });
Lesson.hasMany(LessonObjective, { as: "objectives", foreignKey: "lesson_id" });
// ── UnitQuiz ──────────────────────────────────────────────────────────────────
UnitQuiz.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" });
UnitQuiz.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
UnitQuiz.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
UnitQuiz.hasMany(QuizQuestion, { as: "questions", foreignKey: "quiz_id" });
// ── CourseAssessment ──────────────────────────────────────────────────────────
CourseAssessment.belongsTo(Course, { as: "course", foreignKey: "course_id" });
CourseAssessment.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
CourseAssessment.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
CourseAssessment.hasMany(QuizQuestion, { as: "questions", foreignKey: "assessment_id" });
// ── QuizQuestion ──────────────────────────────────────────────────────────────
QuizQuestion.belongsTo(UnitQuiz, { as: "quiz", foreignKey: "quiz_id" });
QuizQuestion.belongsTo(CourseAssessment, { as: "assessment", foreignKey: "assessment_id" });
QuizQuestion.hasMany(QuizOption, { as: "options", foreignKey: "question_id" });
// ── QuizOption ────────────────────────────────────────────────────────────────
QuizOption.belongsTo(QuizQuestion, { as: "question", foreignKey: "question_id" });
// ── QuizAttempt ───────────────────────────────────────────────────────────────
QuizAttempt.belongsTo(UnitQuiz, { as: "quiz", foreignKey: "quiz_id" });
QuizAttempt.belongsTo(CourseAssessment, { as: "assessment", foreignKey: "assessment_id" });
QuizAttempt.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" });
UnitQuiz.hasMany(QuizAttempt, { as: "attempts", foreignKey: "quiz_id" });
CourseAssessment.hasMany(QuizAttempt, { as: "attempts", foreignKey: "assessment_id" });
// ── AssessmentSession ─────────────────────────────────────────────────────────
AssessmentSession.belongsTo(CourseAssessment, { as: "assessment", foreignKey: "assessment_id" });
AssessmentSession.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" });
CourseAssessment.hasMany(AssessmentSession, { as: "sessions", foreignKey: "assessment_id" });
// ── QuizSession ───────────────────────────────────────────────────────────────
QuizSession.belongsTo(UnitQuiz, { as: "quiz", foreignKey: "quiz_id" });
QuizSession.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" });
UnitQuiz.hasMany(QuizSession, { as: "sessions", foreignKey: "quiz_id" });
module.exports = {
Course, CourseProductCategory,
Unit, Lesson, LessonPage,
CourseUnit, UnitLesson,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseRole, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
AssessmentSession, QuizSession,
mdl_Category, Certificate, CourseInstructor,
CourseReadingProgress, UnitReadingProgress, LessonReadingProgress,
CourseAchievement,
CompletionRequirement, CompletionRequirementProgress,
};
@@ -0,0 +1,54 @@
const excludeAttributes = [
];
const jsonbSchemas = {
// Add here
};
// Different exclude sets per role
const adminExclude = [
...excludeAttributes,
// admins can see audit fields, so nothing extra excluded
];
const userExclude = [
...excludeAttributes,
// regular users cannot see audit trails
"created_by", "updated_by", "deleted_by",
"deleted_at",
];
const computedAttributes = [
{
key: "unitCount",
label: "Units",
type: "number",
order: 5,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "units"
INNER JOIN "course_units" ON "course_units"."unit_id" = "units"."unit_id"
WHERE "course_units"."course_id" = "Course"."course_id"
AND "units"."deletedAt" IS NULL
)`,
filterable: false,
},
{
key: "lessonCount",
label: "Lessons",
type: "number",
order: 6,
literal: `(
SELECT CAST(COUNT(DISTINCT "lessons"."lesson_id") AS INTEGER)
FROM "lessons"
INNER JOIN "unit_lessons" ON "unit_lessons"."lesson_id" = "lessons"."lesson_id"
INNER JOIN "units" ON "unit_lessons"."unit_id" = "units"."unit_id" AND "units"."deletedAt" IS NULL
INNER JOIN "course_units" ON "course_units"."unit_id" = "units"."unit_id"
WHERE "course_units"."course_id" = "Course"."course_id"
AND "lessons"."deletedAt" IS NULL
)`,
filterable: false,
},
];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes };
+38
View File
@@ -0,0 +1,38 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const Course = sequelize.define("Course", {
course_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: true },
title: { type: DataTypes.STRING(255), allowNull: false, hidden: false, order: 2, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, hidden: true, order: 0, filterable: true },
course_code: { type: DataTypes.STRING(50), allowNull: true, unique: true, hidden: false, order: 1, filterable: true },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false },
level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true },
subscription: { type: DataTypes.STRING(50), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true },
status: { type: DataTypes.ENUM("draft", "published", "unpublished"), allowNull: false, defaultValue: "draft", hidden: false, order: 5, filterable: true },
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false },
badge_color: { type: DataTypes.STRING(50), allowNull: true, defaultValue: "purple", hidden: true },
badge_asset_id: { type: DataTypes.BIGINT, allowNull: true, hidden: true },
badge_image_url: { type: DataTypes.TEXT, allowNull: true, hidden: true },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "courses",
timestamps: true,
paranoid: true,
});
// ── Junction Tables ───────────────────────────────────────────────────────────
const CourseProductCategory = sequelize.define("CourseProductCategory", {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
category_id: { type: DataTypes.BIGINT, allowNull: false },
}, { tableName: "course_product_categories", timestamps: true });
module.exports = {
Course,
CourseProductCategory,
};
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const LessonObjective = sequelize.define("LessonObjective", {
objective_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
lesson_id: { type: DataTypes.BIGINT, allowNull: false },
text: { type: DataTypes.TEXT, allowNull: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "lesson_objectives",
timestamps: true,
paranoid: true,
});
module.exports = LessonObjective
@@ -0,0 +1,16 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const LessonPage = sequelize.define("LessonPage", {
page_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
lesson_id: { type: DataTypes.BIGINT, allowNull: false, unique: true },
blocks: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] },
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
}, {
tableName: "lesson_pages",
timestamps: true,
paranoid: false,
});
module.exports = LessonPage;
@@ -0,0 +1,85 @@
/***********************************************************************************************************************************************************************
* File Name: lesson_reading_progress.mdl.js
* Type of Program: Model
* Description: Tracks a user's reading progress at the lesson level.
*
* LessonReadingProgress — one row per (user, lesson).
* UPSERT key: (user_id, lesson_id)
* status is set directly by the caller ('in_progress' | 'completed').
* last_accessed_at updated on every UPSERT.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 26, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const LessonReadingProgress = sequelize.define('LessonReadingProgress', {
progress_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
course_id: {
type: DataTypes.BIGINT,
allowNull: true, // NULL when the lesson is read standalone (outside any course)
references: { model: 'courses', key: 'course_id' },
onDelete: 'CASCADE',
},
unit_id: {
type: DataTypes.BIGINT,
allowNull: true, // NULL when the lesson is read standalone (outside any unit)
references: { model: 'units', key: 'unit_id' },
onDelete: 'CASCADE',
},
lesson_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'lessons', key: 'lesson_id' },
onDelete: 'CASCADE',
},
status: {
type: DataTypes.ENUM('in_progress', 'completed'),
allowNull: false,
defaultValue: 'in_progress',
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Set when status flips to completed. Null while in_progress.',
},
last_accessed_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: 'Updated on every UPSERT.',
},
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'lesson_reading_progress',
timestamps: true,
paranoid: false,
indexes: [
{
unique: true,
fields: ['user_id', 'lesson_id'],
name: 'uq_lrp_user_lesson',
},
{ fields: ['user_id'], name: 'idx_lrp_user_id' },
{ fields: ['course_id'], name: 'idx_lrp_course_id' },
{ fields: ['unit_id'], name: 'idx_lrp_unit_id' },
{ fields: ['lesson_id'], name: 'idx_lrp_lesson_id' },
{ fields: ['status'], name: 'idx_lrp_status' },
],
});
module.exports = LessonReadingProgress;
+30
View File
@@ -0,0 +1,30 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
// Standalone entity — no unit_id / order_index here. A Lesson is attached to
// zero or more Units through unit_lessons, where per-unit ordering lives.
const Lesson = sequelize.define("Lesson", {
lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: false },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: false },
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true },
// order 2 is reserved for the computed "Affiliated" (course_count) column, order 3 for computed "Course Status" — see LESSON_LIST_COMPUTED in lessons.controller.js
subscription: { type: DataTypes.STRING(50), allowNull: true, label: "Subscription", hidden: false, order: 4, filterable: true, comment: "Optional direct tier gate for standalone lessons — null means open (or gated only via an attached unit)." },
description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false },
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, filterable: false }, // computed from blocks on save
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By", filterable: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By", filterable: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By", filterable: true },
}, {
tableName: "lessons",
timestamps: true,
paranoid: true,
indexes: [
{ fields: ["uuid"] },
{ fields: ["deletedAt"] },
],
});
module.exports = Lesson;
@@ -0,0 +1,62 @@
/***********************************************************************************************************************************************************************
* File Name: media_playback_position.mdl.js
* Type of Program: Model
* Description: Per-user "where did I last leave off" position for a video/audio block, entirely
* decoupled from CompletionRequirement — tracked for ANY video/audio block regardless
* of whether the lesson has a watch-type completion requirement configured. Powers
* resume-on-reopen only; carries no completion/anti-cheat semantics (that's
* CompletionRequirementProgress's job, see completion_requirement_progress.mdl.js).
*
* MediaPlaybackPosition — UPSERT key: (user_id, lesson_id, block_id). Last-write-wins, not a
* ratcheted max — a deliberate rewind-and-stop should resume there, not
* snap back to a previously-reached high-water mark.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 17, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const MediaPlaybackPosition = sequelize.define('MediaPlaybackPosition', {
position_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
lesson_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'lessons', key: 'lesson_id' },
onDelete: 'CASCADE',
},
block_id: {
type: DataTypes.STRING,
allowNull: false,
comment: 'The block\'s own id within LessonPage.blocks JSONB — no DB-level FK, blocks are not their own table.',
},
percent: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Last-known % position, 0-100. Last-write-wins — not a ratcheted max.',
},
}, {
tableName: 'media_playback_positions',
timestamps: true,
paranoid: false, // position rows are never soft-deleted, matches CompletionRequirementProgress
indexes: [
{
unique: true,
fields: ['user_id', 'lesson_id', 'block_id'],
name: 'uq_mpp_user_lesson_block',
},
{ fields: ['user_id', 'lesson_id'], name: 'idx_mpp_user_lesson' },
],
});
module.exports = MediaPlaybackPosition;
@@ -0,0 +1,34 @@
/***********************************************************************************************************************************************************************
* File Name: pending_certificate.mdl.js
* Type of Program: Model
* Description: Holds certificates queued for issuance after a 5-minute delay
* following a passed course assessment. The cron job
* (cron/jobs/issue_certificates.cron.js) runs hourly on the hour
* and processes rows where issue_at <= NOW().
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 24, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const PendingCertificate = sequelize.define('PendingCertificate', {
pending_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: DataTypes.BIGINT, allowNull: false },
course_id: { type: DataTypes.BIGINT, allowNull: false },
course_uuid: { type: DataTypes.STRING(36), allowNull: false },
course_title: { type: DataTypes.TEXT, allowNull: true },
passed_at: { type: DataTypes.DATE, allowNull: false },
issue_at: { type: DataTypes.DATE, allowNull: false },
processed_at: { type: DataTypes.DATE, allowNull: true },
}, {
tableName: 'pending_certificates',
timestamps: true,
indexes: [
{ fields: ['user_id'] },
{ fields: ['issue_at'] },
{ unique: true, fields: ['user_id', 'course_id'] },
],
});
module.exports = PendingCertificate;
+38
View File
@@ -0,0 +1,38 @@
'use strict';
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Course } = require('./courses.mdl');
// Polymorphic target — a product is sold against a Course (purchasable_type +
// purchasable_id). Unit/Lesson individual purchase was removed; the schema
// stays polymorphic (purchasable_type/purchasable_id, not a course_id FK)
// since existing rows and course_purchases still key off it. See
// utils/purchasable.util.js for the type -> model/checkout-path resolver used
// by every consumer (access checks, admin CRUD, checkout).
//
// constraints: false below because purchasable_id doesn't point at a single
// table — referential integrity across the three possible targets is
// enforced at the application layer only (see
// 20270101000078-generalize-products-purchasable.js).
const mdl_Product = sequelize.define('Product', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
purchasable_type: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'course' },
purchasable_id: { type: DataTypes.BIGINT, allowNull: false },
name: { type: DataTypes.STRING(200), allowNull: false },
description: { type: DataTypes.TEXT, allowNull: true },
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
currency: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'USD' },
access_days: { type: DataTypes.INTEGER, allowNull: true },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
}, {
tableName: 'products',
timestamps: true,
paranoid: true,
});
// Scoped hasOne — Sequelize automatically adds the matching purchasable_type
// filter to the join, so existing `include: [{ model: mdl_Product, as:
// 'product' }]` call sites on Course keep working unchanged.
Course.hasOne(mdl_Product, { foreignKey: 'purchasable_id', constraints: false, scope: { purchasable_type: 'course' }, as: 'product' });
module.exports = mdl_Product;
@@ -0,0 +1,24 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const QuizAttempt = sequelize.define("QuizAttempt", {
attempt_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
user_id: { type: DataTypes.BIGINT, allowNull: false },
// polymorphic: belongs to either unit_quiz OR course_assessment
quiz_id: { type: DataTypes.BIGINT, allowNull: true }, // → unit_quizzes
assessment_id: { type: DataTypes.BIGINT, allowNull: true }, // → course_assessments
course_id: { type: DataTypes.BIGINT, allowNull: true }, // denormalized — for fast per-course queries
answers: { type: DataTypes.JSONB, allowNull: false, defaultValue: {} },
attempt_number: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 }, // 1-based per user+quiz
total_points: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
earned_points: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
score: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, // percentage 0-100
passing_score: { type: DataTypes.INTEGER, allowNull: true }, // snapshot at submit time
passed: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
}, {
tableName: "quiz_attempts",
timestamps: true,
});
module.exports = QuizAttempt;
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const QuizOption = sequelize.define("QuizOption", {
option_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
question_id: { type: DataTypes.BIGINT, allowNull: false },
text: { type: DataTypes.TEXT, allowNull: false },
is_correct: { type: DataTypes.BOOLEAN, defaultValue: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "quiz_options",
timestamps: true,
});
module.exports = QuizOption
@@ -0,0 +1,28 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const QuizQuestion = sequelize.define("QuizQuestion", {
question_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
// polymorphic: belongs to either unit_quiz OR course_assessment
quiz_id: { type: DataTypes.BIGINT, allowNull: true }, // → unit_quizzes
assessment_id: { type: DataTypes.BIGINT, allowNull: true }, // → course_assessments
type: {
type: DataTypes.ENUM("true_false", "multiple_choice", "multi_select"),
allowNull: false,
},
question: { type: DataTypes.TEXT, allowNull: false },
explanation: { type: DataTypes.TEXT, allowNull: true }, // shown after answer
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
points: { type: DataTypes.INTEGER, defaultValue: 1 },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "quiz_questions",
timestamps: true,
paranoid: true,
});
module.exports = QuizQuestion
@@ -0,0 +1,20 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const QuizSession = sequelize.define("QuizSession", {
session_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
user_id: { type: DataTypes.BIGINT, allowNull: false },
quiz_id: { type: DataTypes.BIGINT, allowNull: false },
course_id: { type: DataTypes.BIGINT, allowNull: true },
unit_id: { type: DataTypes.BIGINT, allowNull: true },
status: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'in_progress' }, // 'in_progress' | 'submitted'
draft_answers: { type: DataTypes.JSONB, allowNull: true, defaultValue: null },
started_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
last_saved_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
}, {
tableName: "quiz_sessions",
timestamps: true,
});
module.exports = QuizSession;
@@ -0,0 +1,32 @@
/***********************************************************************************************************************************************************************
* File Name: unit_lessons.mdl.js
* Type of Program: Model (junction)
* Description: Attaches a standalone Lesson to a Unit. A Lesson can live in many
* Units; per-unit ordering lives here (order_index), not on the
* Lesson itself. Detaching removes the row — the Lesson survives.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 7, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const UnitLesson = sequelize.define("UnitLesson", {
unit_lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
unit_id: { type: DataTypes.BIGINT, allowNull: false },
lesson_id: { type: DataTypes.BIGINT, allowNull: false },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "unit_lessons",
timestamps: true,
indexes: [
{ unique: true, fields: ["unit_id", "lesson_id"], name: "uq_unit_lessons_unit_lesson" },
{ fields: ["unit_id"], name: "idx_unit_lessons_unit_id" },
{ fields: ["lesson_id"], name: "idx_unit_lessons_lesson_id" },
{ fields: ["order_index"], name: "idx_unit_lessons_order" },
],
});
module.exports = UnitLesson;
+22
View File
@@ -0,0 +1,22 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const UnitQuiz = sequelize.define("UnitQuiz", {
quiz_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
unit_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one quiz per unit
title: { type: DataTypes.STRING(255), allowNull: true },
is_required: { type: DataTypes.BOOLEAN, defaultValue: false },
passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, // percentage
max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all
shuffle_questions: { type: DataTypes.BOOLEAN, defaultValue: false },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "unit_quizzes",
timestamps: true,
paranoid: true,
});
module.exports = UnitQuiz
@@ -0,0 +1,78 @@
/***********************************************************************************************************************************************************************
* File Name: unit_reading_progress.mdl.js
* Type of Program: Model
* Description: Tracks a user's reading progress at the unit level.
*
* UnitReadingProgress — one row per (user, unit).
* UPSERT key: (user_id, unit_id)
* status flips in_progress → completed when all sibling lessons are completed.
* last_accessed_at updated on every UPSERT.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 26, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const UnitReadingProgress = sequelize.define('UnitReadingProgress', {
progress_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
course_id: {
type: DataTypes.BIGINT,
allowNull: true, // NULL when the unit is read standalone (outside any course)
references: { model: 'courses', key: 'course_id' },
onDelete: 'CASCADE',
},
unit_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'units', key: 'unit_id' },
onDelete: 'CASCADE',
},
status: {
type: DataTypes.ENUM('in_progress', 'completed'),
allowNull: false,
defaultValue: 'in_progress',
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Set when status flips to completed. Null while in_progress.',
},
last_accessed_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: 'Updated on every UPSERT.',
},
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'unit_reading_progress',
timestamps: true,
paranoid: false,
indexes: [
{
unique: true,
fields: ['user_id', 'unit_id'],
name: 'uq_urp_user_unit',
},
{ fields: ['user_id'], name: 'idx_urp_user_id' },
{ fields: ['course_id'], name: 'idx_urp_course_id' },
{ fields: ['unit_id'], name: 'idx_urp_unit_id' },
{ fields: ['status'], name: 'idx_urp_status' },
],
});
module.exports = UnitReadingProgress;
+27
View File
@@ -0,0 +1,27 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
// Standalone entity — no course_id / order_index here. A Unit is attached to
// zero or more Courses through course_units, where per-course ordering lives.
const Unit = sequelize.define("Unit", {
unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: false },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: false },
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true },
// order 2 is reserved for the computed "Affiliated" (course_count) column, order 3 for computed "Course Status" — see UNIT_LIST_COMPUTED in units.controller.js
subscription: { type: DataTypes.STRING(50), allowNull: true, label: "Subscription", hidden: false, order: 4, filterable: true, comment: "Optional direct tier gate for standalone units — null means open (or gated only via an attached course)." },
description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false },
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 5, filterable: false },
createdBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
}, {
tableName: "units",
timestamps: true,
paranoid: true,
indexes: [
{ fields: ["uuid"] },
{ fields: ["deletedAt"] },
],
});
module.exports = Unit;