mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user