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,62 @@
// models/advertisements/advertisements.attributes.js
// ─── Exclude sets ─────────────────────────────────────────────────────────────
// Fields hidden from all roles (internal/denormalized details)
const excludeAttributes = [
"image_asset_id", // internal FK, "image" association exposes the resolved asset instead
];
// Admins see everything except the base excludes
const adminExclude = [
...excludeAttributes,
];
// Regular users also cannot see audit trails, soft-delete info, or click metrics
const userExclude = [
...excludeAttributes,
"click_count",
"createdBy",
"updatedBy",
"deletedBy",
"deletedAt",
];
// ─── JSONB schemas ──────────────────────────────────────────────────────────
// Describes shape of the `ctas` JSONB column for paginate's coercion/validation step.
const jsonbSchemas = {
ctas: {
type: "array",
itemShape: {
label: "string",
link: "string",
variant: "string", // "default" | "outline"
},
},
badge_labels: {
type: "array",
itemShape: "string",
},
};
// ─── Computed attributes ──────────────────────────────────────────────────────
// Add any SQL-computed fields here (e.g. a "is_currently_live" derived flag).
// Format: { key, label, type, order, literal }
const computedAttributes = [
// Example:
// {
// key: "is_live",
// label: "Currently Live",
// type: "boolean",
// order: 99,
// literal: `("Advertisement"."is_active" = true AND "Advertisement"."start_date" <= NOW() AND ("Advertisement"."end_date" IS NULL OR "Advertisement"."end_date" >= NOW()))`,
// },
];
module.exports = {
excludeAttributes,
adminExclude,
userExclude,
jsonbSchemas,
computedAttributes,
};
@@ -0,0 +1,98 @@
// models/advertisements/advertisements.mdl.js
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl");
const mdl_Assets = require("../assets/assets.mdl");
const Advertisement = sequelize.define("Advertisement", {
// ─── Identity ─────────────────────────────────────────────────────────────
advertisement_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Advertisement ID", order: 0, hidden: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
// ─── Identification / placement ──────────────────────────────────────────
// placement is the source of truth for "where" (a registry key, see
// advertisements.placements.js); type is denormalized from it on every
// write (applyAdvertisementFields) and describes "what it looks like".
placement: {
type: DataTypes.STRING(100),
allowNull: true,
filterable: true,
label: "Placement", order: 1
},
type: {
type: DataTypes.ENUM("hero", "banner", "popup", "sidebar"),
allowNull: false,
filterable: true,
label: "Format", order: 2
},
status: {
type: DataTypes.ENUM("draft", "active", "scheduled", "expired", "archived"),
allowNull: false,
defaultValue: "draft", label: "Status", order: 3
},
// ─── Content ──────────────────────────────────────────────────────────────
// Every ad is now created/edited as "content" (badge/headline/description/
// image/link all mandatory) — applyAdvertisementFields on the backend fixes
// this server-side rather than accepting it from the client. "image" is
// legacy-only, left on rows created before the Image Only / Text with Image
// toggle was removed, until they're next edited.
content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 },
// Up to 2 outline-badge chips shown alongside the headline — see MAX_BADGE_LABELS.
badge_labels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "Badge Labels", order: 4 },
headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 },
description: { type: DataTypes.TEXT, label: "Description", order: 6 },
// ─── Media ────────────────────────────────────────────────────────────────
image_url: { type: DataTypes.STRING(512), label: "Image URL", order: 0, hidden: true },
image_asset_id: { type: DataTypes.BIGINT, allowNull: true, label: "Image Asset", order: 0, hidden: true },
// ─── Calls-to-action ──────────────────────────────────────────────────────
// [{ label, link }, ...] — 0-2 entries depending on type
ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 7 },
// Where the ad as a whole links to when clicked with no CTA of its own
// (banners/full-image ads have no CTA row) — takes priority over landing_page.
redirect_link: { type: DataTypes.STRING(512), allowNull: true, label: "Redirect Link", order: 7.5 },
// Internally-authored landing page { title, description, body, links: [{label, link}] }
// used as the click-through destination when redirect_link is empty — see
// GET /api/client/advertisements/uuid/:uuid and the /ads/:uuid client route.
landing_page: { type: DataTypes.JSONB, allowNull: true, label: "Landing Page", order: 0, hidden: true },
// ─── Scheduling ───────────────────────────────────────────────────────────
start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 },
end_date: { type: DataTypes.DATE, label: "End Date", order: 9 },
// ─── Display behavior ─────────────────────────────────────────────────────
order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 10 },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 11 },
size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 12 }, // banner-only, ignored by other types
// ─── Metrics ──────────────────────────────────────────────────────────────
click_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Clicks", order: 13, hidden: true },
// ─── Audit trails ─────────────────────────────────────────────────────────
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: "advertisements",
timestamps: true, // createdAt, updatedAt
paranoid: true,
indexes: [
{ fields: ["uuid"] },
{ fields: ["type"] },
{ fields: ["placement"] },
{ fields: ["status"] },
{ fields: ["is_active"] },
{ fields: ["deletedAt"] },
],
});
Advertisement.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
Advertisement.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Advertisement.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
module.exports = Advertisement;
@@ -0,0 +1,28 @@
// models/advertisements/advertisements.placements.js
//
// Declarative registry of every ad placement in the client app. Each entry
// is a self-contained "slot" — a page + position pair — that determines the
// visual format (hero/banner/popup/sidebar) automatically. Adding a new
// placement should only ever require adding one entry here (and wiring the
// corresponding client page to fetch/render it) — nothing else in this file
// should need to change.
//
// `key` is what's stored on advertisements.placement. `format` is what gets
// denormalized onto advertisements.type on write (see applyAdvertisementFields
// in controllers/admin/advertisements.controller.js) — type is never accepted
// from the client once a placement is set.
const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Subscriptions", slotLabel: "Banner (above plan cards)" },
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
];
const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
const PLACEMENT_KEYS = PLACEMENTS.map((p) => p.key);
function getFormatForPlacement(key) {
return PLACEMENT_MAP[key]?.format ?? null;
}
module.exports = { PLACEMENTS, PLACEMENT_MAP, PLACEMENT_KEYS, getFormatForPlacement };
@@ -0,0 +1,48 @@
// models/assets/assets.attributes.js
// ─── Exclude sets ─────────────────────────────────────────────────────────────
// Fields hidden from all roles (sensitive / internal storage details)
const excludeAttributes = [
"checksum", // internal integrity hash, not useful to clients
"storage_bucket", // internal storage config
"storage_key", // internal Chibisafe / S3 key
];
// Admins see everything except the base excludes
const adminExclude = [
...excludeAttributes,
];
// Regular users also cannot see audit trails or soft-delete info
const userExclude = [
...excludeAttributes,
"uploadedBy",
"deletedAt",
];
// ─── No JSONB columns on assets ───────────────────────────────────────────────
// Assets has no JSONB columns so jsonbSchemas stays empty.
const jsonbSchemas = {};
// ─── Computed attributes ──────────────────────────────────────────────────────
// Add any SQL-computed fields here (e.g. a view count join).
// Format: { key, label, type, order, literal }
const computedAttributes = [
// Example:
// {
// key: "viewCount",
// label: "Views",
// type: "number",
// order: 99,
// literal: `(SELECT COUNT(*) FROM "asset_views" WHERE "asset_views"."asset_id" = "Asset"."asset_id")`,
// },
];
module.exports = {
excludeAttributes,
adminExclude,
userExclude,
jsonbSchemas,
computedAttributes,
};
+85
View File
@@ -0,0 +1,85 @@
// models/Asset.js
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl")
const Asset = sequelize.define("Asset", {
// ─── Identity ─────────────────────────────────────────────────────────────
asset_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Asset ID", order: 0, hidden: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
// ─── File info ────────────────────────────────────────────────────────────
original_name: { type: DataTypes.STRING(255), allowNull: false, label: "Original Name", order: 0, hidden: true },
display_name: { type: DataTypes.STRING(255), allowNull: false, label: "Name", order: 0, filterable: true },
file_url: { type: DataTypes.STRING(512), allowNull: false, label: "File URL", order: 0, hidden: true },
file_size: { type: DataTypes.BIGINT, allowNull: false, label: "File Size", order: 0 },
mime_type: { type: DataTypes.STRING(100), allowNull: false, label: "MIME Type", order: 0, hidden: true },
extension: { type: DataTypes.STRING(20), label: "File Type", order: 0, filterable: true },
checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true },
// ─── Classification ───────────────────────────────────────────────────────
file_type: {
type: DataTypes.ENUM("avatar", "document", "video", "image", "audio"),
allowNull: false,
defaultValue: "image", label: "Classification", order: 0
},
// ─── Image & video dimensions ─────────────────────────────────────────────
width: { type: DataTypes.INTEGER, label: "", order: 0, hidden: true },
height: { type: DataTypes.INTEGER, label: "", order: 0, hidden: true },
// ─── Video-specific ───────────────────────────────────────────────────────
duration: { type: DataTypes.FLOAT, label: "", order: 0, hidden: true }, // seconds
resolution: { type: DataTypes.STRING(20), label: "", order: 0, hidden: true }, // "1080p", "720p", "4K"
frame_rate: { type: DataTypes.FLOAT, label: "", order: 0, hidden: true }, // fps
bitrate: { type: DataTypes.BIGINT, label: "", order: 0, hidden: true }, // bps
video_codec: { type: DataTypes.STRING(50), label: "", order: 0, hidden: true }, // "H.264", "H.265"
audio_codec: { type: DataTypes.STRING(50), label: "", order: 0, hidden: true }, // "AAC", "MP3"
thumbnail_url: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true },
thumbnail_storage_key: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true },
// ─── Background remux (.mov/.mkv → faststart .mp4) ───────────────────────
// "none" — asset never needed a remux (not video, or already a fast format).
// STRING, not ENUM — matches the STRING+CHECK column (see migration
// 20270101000076), not a real Postgres enum type.
transcode_status: { type: DataTypes.STRING(20), defaultValue: "none", label: "", order: 0, hidden: true },
transcode_error: { type: DataTypes.TEXT, label: "", order: 0, hidden: true },
// ─── Description ──────────────────────────────────────────────────────────
description: { type: DataTypes.TEXT, label: "Description", hidden: true },
// ─── Storage ──────────────────────────────────────────────────────────────
storage_provider: {
type: DataTypes.ENUM("local", "s3", "gcs", "cloudinary", "chibisafe", "other"),
defaultValue: "local", label: "Storage Provider", order: 0
},
storage_bucket: { type: DataTypes.STRING(255), label: "", order: 0, hidden: true },
storage_key: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true },
// ─── Access control ───────────────────────────────────────────────────────
is_public: {
type: DataTypes.BOOLEAN,
defaultValue: false, label: "Access Type", order: 0
},
// ── Audit trails ────────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By", filterable: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By", filterable: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By", filterable: true },
}, {
tableName: "assets",
timestamps: true, // createdAt, updatedAt
paranoid: true,
indexes: [
{ fields: ["uuid"] },
{ fields: ["createdBy"] },
{ fields: ["file_type"] },
{ fields: ["deletedAt"] },
],
});
Asset.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Asset.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
module.exports = Asset;
@@ -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;
@@ -0,0 +1,108 @@
/***********************************************************************************************************************************************************************
* File Name : notification.mdl.js
* Type : Sequelize Model
* Description : Admin-facing system notifications. Each row is a single event
* surfaced in the admin bell dropdown (e.g. "5 tasks overdue").
* Not user-scoped — visible to all admins.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Assets = require('../assets/assets.mdl');
const AdminNotification = sequelize.define('AdminNotification', {
notification_id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true,
},
type: {
type: DataTypes.STRING(64),
allowNull: false,
},
title: {
type: DataTypes.STRING(255),
allowNull: false,
},
message: {
type: DataTypes.TEXT,
allowNull: false,
},
// Controls where this notification is rendered in the admin UI.
show_in_notifications: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
show_in_sticky: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
// Named color key (utils/tierColors.js on the frontend) for the sticky banner.
color: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'indigo',
},
// Denormalized copy of the source NotificationBroadcast's per-alert
// layout image (see notification_broadcast.mdl.js) — null when the
// alert has none.
image_asset_id: {
type: DataTypes.BIGINT,
allowNull: true,
},
// Denormalized copy of the source NotificationBroadcast's visibility
// window (see notificationVisibility.util.js) — null means unbounded.
start_date: {
type: DataTypes.DATE,
allowNull: true,
},
end_date: {
type: DataTypes.DATE,
allowNull: true,
},
// Links back to the source broadcast so editing it after send can
// propagate content changes into already-created rows (see
// notificationBroadcasts.controller.js#updateBroadcast). Null for
// notification types with no broadcast source.
broadcast_id: {
type: DataTypes.BIGINT,
allowNull: true,
},
data: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: null,
},
seen: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
seen_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: null,
},
}, {
tableName: 'admin_notifications',
timestamps: true,
indexes: [
{ fields: ['seen'], name: 'idx_an_seen' },
{ fields: ['type'], name: 'idx_an_type' },
{ fields: ['createdAt'], name: 'idx_an_created_at' },
],
});
AdminNotification.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
module.exports = AdminNotification;
@@ -0,0 +1,16 @@
// models/notifications/cron_notification_setting.mdl.js
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CronNotificationSetting = sequelize.define("CronNotificationSetting", {
job_name: { type: DataTypes.STRING(64), primaryKey: true, label: "Job" },
enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Enabled" },
schedule: { type: DataTypes.STRING(20), allowNull: false, label: "Schedule" },
target_status: { type: DataTypes.STRING(20), allowNull: true, label: "Target Status" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
}, {
tableName: "cron_notification_settings",
timestamps: true,
});
module.exports = CronNotificationSetting;
@@ -0,0 +1,24 @@
// models/notifications/notification_broadcast.attributes.js
// ─── Exclude sets ─────────────────────────────────────────────────────────────
const excludeAttributes = [];
// Admins see everything
const adminExclude = [
...excludeAttributes,
];
// ─── JSONB schemas ──────────────────────────────────────────────────────────
// No JSONB columns on this model.
const jsonbSchemas = {};
// ─── Computed attributes ──────────────────────────────────────────────────────
const computedAttributes = [];
module.exports = {
excludeAttributes,
adminExclude,
jsonbSchemas,
computedAttributes,
};
@@ -0,0 +1,87 @@
// models/notifications/notification_broadcast.mdl.js
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl");
const mdl_Assets = require("../assets/assets.mdl");
const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
// ─── Identity ─────────────────────────────────────────────────────────────
broadcast_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Broadcast ID", order: 0, hidden: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
// ─── Content ──────────────────────────────────────────────────────────────
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 },
// Only used by Notifications-type alerts (show_in_notifications) — Sticky
// alerts (show_in_sticky) are title-only and leave this null.
message: { type: DataTypes.TEXT, allowNull: true, label: "Message", order: 1.5 },
// When set, the client's "view full content" dialog shows an "Open Link"
// action pointing here. When null, that dialog is plain text info only.
link_url: { type: DataTypes.STRING(2048), allowNull: true, label: "Link URL", order: 2.2 },
// Custom CTA button text shown right after the title in the sticky banner
// (e.g. "Shop now"). Falls back to "Open Link" when unset.
link_label: { type: DataTypes.STRING(60), allowNull: true, label: "Button Label", order: 2.3 },
// Named color key (see utils/tierColors.js on the frontend) driving the
// sticky banner's panel background/border — same registry rewards/tiers use.
color: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'indigo', label: "Color", order: 2.4 },
// Optional per-alert image shown in the click-through details dialog when
// opened from the sticky banner (see AnnouncementCarouselDialog on the
// frontend). Replaces the old shared sticky_banner_settings singleton.
image_asset_id: { type: DataTypes.BIGINT, allowNull: true, label: "Layout Image", order: 2.45 },
// Optional visibility window. Null start_date = show immediately once sent;
// null end_date = show indefinitely. Copied onto AdminNotification/
// UserNotification rows at send time so client reads can filter without
// joining back to this table (see sendBroadcast + notificationVisibility.util.js).
start_date: { type: DataTypes.DATE, allowNull: true, label: "Start Date", order: 2.7 },
end_date: { type: DataTypes.DATE, allowNull: true, label: "End Date", order: 2.8 },
// ─── Visibility ──────────────────────────────────────────────────────────
// Determines where a delivered announcement shows up for recipients.
// - show_in_sticky: client sticky banner (fixed top)
// - show_in_notifications: client + admin notifications lists/bells
show_in_sticky: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: "Show in Sticky Alerts", order: 2.5 },
show_in_notifications: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Show in Notifications", order: 2.6 },
// ─── Targeting ────────────────────────────────────────────────────────────
target_type: {
type: DataTypes.ENUM("admin", "user", "both", "task_list", "course", "tier_plan"),
allowNull: false,
label: "Target", order: 3
},
// Holds a task list UUID, course UUID, or tier plan ID (stringified) — only
// set when target_type is 'task_list' / 'course' / 'tier_plan'.
target_id: { type: DataTypes.STRING(64), allowNull: true, label: "Target ID", order: 3.5 },
// ─── Lifecycle ────────────────────────────────────────────────────────────
status: {
type: DataTypes.ENUM("draft", "sent", "archived"),
allowNull: false,
defaultValue: "draft", label: "Status", order: 4
},
sent_at: { type: DataTypes.DATE, allowNull: true, label: "Sent At", order: 5 },
recipient_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Recipients", order: 6 },
// ─── Audit trails ─────────────────────────────────────────────────────────
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: "notification_broadcasts",
timestamps: true, // createdAt, updatedAt
paranoid: true,
indexes: [
{ fields: ["uuid"] },
{ fields: ["status"] },
{ fields: ["target_type"] },
{ fields: ["deletedAt"] },
],
});
NotificationBroadcast.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
NotificationBroadcast.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
NotificationBroadcast.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
module.exports = NotificationBroadcast;
@@ -0,0 +1,115 @@
/***********************************************************************************************************************************************************************
* File Name : user_notification.mdl.js
* Type : Sequelize Model
* Description : Client-facing per-user notifications (achievement unlocked,
* task events, etc.). Scoped to a single user_id — each row
* is private to the user it belongs to.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Assets = require('../assets/assets.mdl');
const UserNotification = sequelize.define('UserNotification', {
notification_id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true,
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
},
type: {
type: DataTypes.STRING(64),
allowNull: false,
},
title: {
type: DataTypes.STRING(255),
allowNull: false,
},
message: {
type: DataTypes.TEXT,
allowNull: false,
},
// Controls where this notification is rendered.
// - show_in_notifications: appears in the /notifications list + bell badge.
// - show_in_sticky: appears in the fixed "sticky announcement" banner.
show_in_notifications: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
show_in_sticky: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
// Named color key (utils/tierColors.js on the frontend) for the sticky banner.
color: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'indigo',
},
// Denormalized copy of the source NotificationBroadcast's per-alert
// layout image (see notification_broadcast.mdl.js) — null when the
// alert has none.
image_asset_id: {
type: DataTypes.BIGINT,
allowNull: true,
},
// Denormalized copy of the source NotificationBroadcast's visibility
// window (see notificationVisibility.util.js) — null means unbounded.
start_date: {
type: DataTypes.DATE,
allowNull: true,
},
end_date: {
type: DataTypes.DATE,
allowNull: true,
},
// Links back to the source broadcast so editing it after send can
// propagate content changes into already-created rows (see
// notificationBroadcasts.controller.js#updateBroadcast). Null for
// notification types with no broadcast source.
broadcast_id: {
type: DataTypes.BIGINT,
allowNull: true,
},
data: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: null,
},
seen: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
seen_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: null,
},
}, {
tableName: 'user_notifications',
timestamps: true,
indexes: [
{ fields: ['user_id'], name: 'idx_un_user_id' },
{ fields: ['user_id', 'seen'], name: 'idx_un_seen' },
{ fields: ['type'], name: 'idx_un_type' },
{ fields: ['createdAt'], name: 'idx_un_created_at' },
],
});
UserNotification.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
module.exports = UserNotification;
@@ -0,0 +1,19 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_SystemBadges = sequelize.define('SystemBadge', {
badge_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
key: { type: DataTypes.STRING(50), allowNull: false, unique: true, label: 'Badge Key' },
asset_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Asset' },
label: { type: DataTypes.STRING(100), allowNull: false, label: 'Label' },
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' },
information: { type: DataTypes.TEXT, allowNull: true, label: 'Information' },
active_from: { type: DataTypes.DATEONLY, allowNull: true, label: 'Active From' },
active_until: { type: DataTypes.DATEONLY, allowNull: true, label: 'Active Until' },
}, {
tableName: 'system_badges',
timestamps: true,
paranoid: false,
});
module.exports = mdl_SystemBadges;
+20
View File
@@ -0,0 +1,20 @@
const excludeAttributes = [];
const adminExclude = [
...excludeAttributes,
// admins can see audit fields, so nothing extra excluded
];
const userExclude = [
...excludeAttributes,
// regular users cannot see audit trails
"createdBy", "updatedBy", "deletedBy", "deletedAt",
];
const jsonbSchemas = {};
const computedAttributes = [];
const filterableFields = {}
module.exports = { adminExclude, userExclude, excludeAttributes, jsonbSchemas, computedAttributes, filterableFields };
+123
View File
@@ -0,0 +1,123 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const { mdl_UserGroups } = require('../users/user_groups.mdl');
const TaskList = sequelize.define('TaskList', {
task_list_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, hidden: true },
name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: true }, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, hidden: true, filterable: false },
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },
updatedBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },
deletedBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },
}, {
tableName: 'task_lists',
paranoid: true,
timestamps: true,
});
// ─── Junction: TaskList ↔ UserGroups ──────────────────────────────────────────
const TaskListGroup = sequelize.define('TaskListGroup', {
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
task_list_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, onDelete: 'CASCADE', },
group_id: { type: DataTypes.BIGINT, allowNull: false, references: { model: 'user_groups', key: 'group_id' }, onDelete: 'CASCADE', },
assignedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, allowNull: false, filterable: true },
assignedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
}, {
tableName: 'task_list_groups',
timestamps: false,
indexes: [
{ unique: true, fields: ['task_list_id', 'group_id'], name: 'uq_task_list_group' },
{ fields: ['task_list_id'], name: 'idx_tlg_task_list_id' },
{ fields: ['group_id'], name: 'idx_tlg_group_id' },
],
});
const Task = sequelize.define('Task', {
task_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, hidden: true },
task_list_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, hidden: true, filterable: false },
name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: true }, order: 1, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, hidden: true, filterable: false },
deadline: { type: DataTypes.DATE, allowNull: true, order: 2, filterable: true },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, order: 2.1, filterable: true, comment: 'Position within the task list — display order only. Locking is driven solely by explicit task_prerequisites.' },
accepts_submissions: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, order: 2.25, filterable: true, comment: 'When false, no new TaskCompletion submissions are accepted for this task (existing completions are unaffected).' },
status: { type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'), defaultValue: 'pending', allowNull: false, filterable: true },
auto_marked_at: { type: DataTypes.DATE, allowNull: true, filterable: true, comment: 'Set only by the taskOverdue cron sweep when it auto-flips status; never touched by user-driven completion.' },
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },
updatedBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },
deletedBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },
}, {
tableName: 'tasks',
paranoid: true,
timestamps: true,
});
const TaskRequirement = sequelize.define('TaskRequirement', {
requirement_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' } },
type: { type: DataTypes.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson', 'submit_text'), allowNull: false, filterable: true },
// ── visit_link ──────────────────────────────────────────────────────────
link_url: { type: DataTypes.STRING, allowNull: true, filterable: false },
link_label: { type: DataTypes.STRING, allowNull: true, filterable: false },
// ── upload_file ─────────────────────────────────────────────────────────
allowed_file_types: { type: DataTypes.JSONB, allowNull: true, filterable: false },
max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1, filterable: false },
// ── read_course / read_unit / read_lesson ──────────────────────────────────
reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id depending on type', filterable: false },
reference_label: { type: DataTypes.STRING, allowNull: true, comment: 'Cached display name so we do not always join', filterable: false },
// ── submit_text ─────────────────────────────────────────────────────────
prompt: { type: DataTypes.TEXT, allowNull: true, comment: 'Instructions shown above the free-text response box.', filterable: false },
// ── upload_file / submit_text ──────────────────────────────────────────
requires_review: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, filterable: true, comment: 'If true, a submission only counts as complete once an admin approves it.' },
order: { type: DataTypes.INTEGER, defaultValue: 0, filterable: true },
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.INTEGER, allowNull: true },
updatedBy: { type: DataTypes.INTEGER, allowNull: true },
deletedBy: { type: DataTypes.INTEGER, allowNull: true },
}, {
tableName: 'task_requirements',
paranoid: true,
timestamps: true,
});
// ─── Junction: Task ↔ Task (explicit prerequisite graph) ──────────────────────
const TaskPrerequisite = sequelize.define('TaskPrerequisite', {
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE', },
prerequisite_task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE', },
}, {
tableName: 'task_prerequisites',
timestamps: true,
indexes: [
{ unique: true, fields: ['task_id', 'prerequisite_task_id'], name: 'uq_task_prerequisite' },
{ fields: ['task_id'], name: 'idx_tp_task_id' },
{ fields: ['prerequisite_task_id'], name: 'idx_tp_prerequisite_task_id' },
],
});
TaskList.hasMany(Task, { foreignKey: 'task_list_id', as: 'tasks' });
Task.belongsTo(TaskList, { foreignKey: 'task_list_id', as: 'taskList' });
Task.hasMany(TaskRequirement, { foreignKey: 'task_id', as: 'requirements' });
TaskRequirement.belongsTo(Task, { foreignKey: 'task_id', as: 'task' });
// TaskList ↔ UserGroups (many-to-many through TaskListGroup)
TaskList.belongsToMany(mdl_UserGroups, { through: TaskListGroup, foreignKey: 'task_list_id', otherKey: 'group_id', as: 'groups', });
mdl_UserGroups.belongsToMany(TaskList, { through: TaskListGroup, foreignKey: 'group_id', otherKey: 'task_list_id', as: 'taskLists', });
// Task ↔ Task (self-referential many-to-many through TaskPrerequisite)
// 'prerequisites' — tasks THIS task depends on. 'dependents' — tasks that depend on THIS task.
Task.belongsToMany(Task, { through: TaskPrerequisite, foreignKey: 'task_id', otherKey: 'prerequisite_task_id', as: 'prerequisites' });
Task.belongsToMany(Task, { through: TaskPrerequisite, foreignKey: 'prerequisite_task_id', otherKey: 'task_id', as: 'dependents' });
module.exports = { Task, TaskList, TaskRequirement, TaskListGroup, TaskPrerequisite, mdl_UserGroups };
@@ -0,0 +1,40 @@
/***********************************************************************************************************************************************************************
* File Name: task_completion.attributes.js
* Type of Program: Attributes / Field Config
* Description: Attribute exclusion lists for TaskCompletion and TaskCompletionFile.
* Admins see audit fields; clients see only their own completion data.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
// ─── Shared base exclusions (neither role needs these) ────────────────────────
const excludeAttributes = [];
// ─── Admin: can see audit trails ──────────────────────────────────────────────
const adminExclude = [
...excludeAttributes,
];
// ─── Client: no audit trails ──────────────────────────────────────────────────
const clientExclude = [
...excludeAttributes,
'createdBy',
'updatedBy',
'deletedBy',
'deletedAt',
'storage_key',
];
const jsonbSchemas = {};
const computedAttributes = [];
const filterableFields = {};
module.exports = {
adminExclude,
clientExclude,
excludeAttributes,
jsonbSchemas,
computedAttributes,
filterableFields,
};
+147
View File
@@ -0,0 +1,147 @@
/***********************************************************************************************************************************************************************
* File Name: task_completion.mdl.js
* Type of Program: Model
* Description: Sequelize models for `task_completions` and `task_completion_files`.
* A completion is created when a user turns in work for a task.
* Multiple completions are allowed (resubmit anytime).
* Files are stored separately — one completion can have many files.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('../users/users.mdl');
const { Task } = require('./task.mdl');
// ─── TaskCompletion ────────────────────────────────────────────────────────────
const TaskCompletion = sequelize.define('TaskCompletion', {
completion_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
order: 3,
},
task_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'tasks', key: 'task_id' },
onDelete: 'CASCADE',
order: 4,
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
order: 5,
},
note: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Optional note the user attaches when submitting.',
order: 1,
},
response_text: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'The learner\'s free-text answer, for submit_text requirements.',
order: 1.5,
},
submitted_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
allowNull: false,
order: 2,
},
// ── Review workflow (requires_review requirements only) ──────────────────
status: {
type: DataTypes.ENUM('submitted', 'approved', 'rejected'),
allowNull: false,
defaultValue: 'submitted',
order: 2.1,
filterable: true,
},
reviewed_by: { type: DataTypes.BIGINT, allowNull: true, order: 2.2 },
reviewed_at: { type: DataTypes.DATE, allowNull: true, order: 2.3 },
review_note: { type: DataTypes.TEXT, allowNull: true, comment: 'Optional note the admin leaves when approving/rejecting.', order: 2.4 },
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, order: 6 },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, order: 7 },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, order: 8 },
}, {
tableName: 'task_completions',
paranoid: true,
timestamps: true,
indexes: [
{ fields: ['task_id'], name: 'idx_tc_task_id' },
{ fields: ['user_id'], name: 'idx_tc_user_id' },
{ fields: ['task_id', 'user_id'], name: 'idx_tc_task_user' },
],
});
// ─── TaskCompletionFile ────────────────────────────────────────────────────────
const TaskCompletionFile = sequelize.define('TaskCompletionFile', {
file_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
completion_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'task_completions', key: 'completion_id' },
onDelete: 'CASCADE',
},
file_url: {
type: DataTypes.STRING,
allowNull: false,
comment: 'Storage path or CDN URL of the uploaded file.',
},
file_name: {
type: DataTypes.STRING,
allowNull: false,
comment: 'Original filename as uploaded by the user.',
},
file_size: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'File size in bytes.',
},
mime_type: {
type: DataTypes.STRING,
allowNull: true,
comment: 'MIME type e.g. application/pdf, image/png.',
},
storage_key: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'S3 object key (e.g. "images/uuid.jpg"), used by the download proxy endpoint.',
},
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'task_completion_files',
paranoid: true,
timestamps: true,
indexes: [
{ fields: ['completion_id'], name: 'idx_tcf_completion_id' },
],
});
// ─── Associations ──────────────────────────────────────────────────────────────
Task.hasMany(TaskCompletion, { foreignKey: 'task_id', as: 'completions' });
TaskCompletion.belongsTo(Task, { foreignKey: 'task_id', as: 'task' });
mdl_Users.hasMany(TaskCompletion, { foreignKey: 'user_id', as: 'taskCompletions' });
TaskCompletion.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
TaskCompletion.hasMany(TaskCompletionFile, { foreignKey: 'completion_id', as: 'files' });
TaskCompletionFile.belongsTo(TaskCompletion, { foreignKey: 'completion_id', as: 'completion' });
module.exports = { TaskCompletion, TaskCompletionFile };
+158
View File
@@ -0,0 +1,158 @@
/***********************************************************************************************************************************************************************
* File Name: task_progress.mdl.js
* Type of Program: Model
* Description: Sequelize models for tracking user progress on task requirements.
*
* TaskLinkVisit — tracks when a user visits a visit_link requirement.
* UPSERT key: (requirement_id, user_id)
* Revisiting updates visited_at.
*
* TaskProgress — tracks completion of read_course / read_unit / read_lesson.
* UPSERT key: (requirement_id, user_id, reference_id)
* Lessons: completed flipped directly by client.
* Units/Courses: completed derived from child completion.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('../users/users.mdl');
const { Task, TaskRequirement } = require('./task.mdl');
// ─── TaskLinkVisit ─────────────────────────────────────────────────────────────
const TaskLinkVisit = sequelize.define('TaskLinkVisit', {
visit_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
task_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'tasks', key: 'task_id' },
onDelete: 'CASCADE',
},
requirement_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'task_requirements', key: 'requirement_id' },
onDelete: 'CASCADE',
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
visited_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
allowNull: false,
comment: 'Updated on every revisit via UPSERT.',
},
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'task_link_visits',
timestamps: true,
paranoid: false, // visits are never soft-deleted
indexes: [
{
unique: true,
fields: ['requirement_id', 'user_id'],
name: 'uq_tlv_requirement_user',
},
{ fields: ['task_id'], name: 'idx_tlv_task_id' },
{ fields: ['user_id'], name: 'idx_tlv_user_id' },
{ fields: ['requirement_id'], name: 'idx_tlv_requirement_id' },
],
});
// ─── TaskProgress ──────────────────────────────────────────────────────────────
const TaskProgress = sequelize.define('TaskProgress', {
progress_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
task_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'tasks', key: 'task_id' },
onDelete: 'CASCADE',
},
requirement_id: {
type: DataTypes.UUID,
allowNull: false,
references: { model: 'task_requirements', key: 'requirement_id' },
onDelete: 'CASCADE',
},
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
reference_id: {
type: DataTypes.UUID,
allowNull: false,
comment: 'course_id | unit_id | lesson_id depending on type.',
},
type: {
type: DataTypes.ENUM('read_course', 'read_unit', 'read_lesson'),
allowNull: false,
},
completed: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Set when completed flips true. Cleared when it reverts to false.',
},
// ── Audit trails ────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'task_progress',
timestamps: true,
paranoid: false, // progress rows are never soft-deleted
indexes: [
{
unique: true,
fields: ['requirement_id', 'user_id', 'reference_id'],
name: 'uq_tp_requirement_user_reference',
},
{ fields: ['task_id'], name: 'idx_tp_task_id' },
{ fields: ['user_id'], name: 'idx_tp_user_id' },
{ fields: ['requirement_id'], name: 'idx_tp_requirement_id' },
{ fields: ['type'], name: 'idx_tp_type' },
],
});
// ─── Associations ──────────────────────────────────────────────────────────────
Task.hasMany(TaskLinkVisit, { foreignKey: 'task_id', as: 'linkVisits' });
TaskLinkVisit.belongsTo(Task, { foreignKey: 'task_id', as: 'task' });
TaskRequirement.hasMany(TaskLinkVisit, { foreignKey: 'requirement_id', as: 'visits' });
TaskLinkVisit.belongsTo(TaskRequirement, { foreignKey: 'requirement_id', as: 'requirement' });
mdl_Users.hasMany(TaskLinkVisit, { foreignKey: 'user_id', as: 'linkVisits' });
TaskLinkVisit.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
Task.hasMany(TaskProgress, { foreignKey: 'task_id', as: 'progress' });
TaskProgress.belongsTo(Task, { foreignKey: 'task_id', as: 'task' });
TaskRequirement.hasMany(TaskProgress, { foreignKey: 'requirement_id', as: 'progress' });
TaskProgress.belongsTo(TaskRequirement, { foreignKey: 'requirement_id', as: 'requirement' });
mdl_Users.hasMany(TaskProgress, { foreignKey: 'user_id', as: 'taskProgress' });
TaskProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
module.exports = { TaskLinkVisit, TaskProgress };
@@ -0,0 +1,36 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_PaymentPolicies = sequelize.define('PaymentPolicy', {
policy_id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
},
plan_id: {
type: DataTypes.BIGINT,
allowNull: true,
unique: true,
},
promo_rules: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
refund_policy: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false },
},
allowed_providers: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: ['paypal'],
},
}, {
tableName: 'payment_policies',
timestamps: true,
paranoid: false,
});
module.exports = mdl_PaymentPolicies;
@@ -0,0 +1,16 @@
/***********************************************************************************************************************************************************************
* File Name: payments.attributes.js
* Type of Program: Model Attributes Config
* Description: DataTable column config for payments — used by paginate.util.js
* Author: rgrgogu
* Date Created: Jun. 9, 2026
***********************************************************************************************************************************************************************/
const excludeAttributes = ['provider_payload'];
const jsonbSchemas = {};
const computedAttributes = [
{ key: 'user_full_name', label: 'Full Name', type: 'text', order: 1, filterable: false, literal: `("user"."personal_info"->'name'->>'full_name')` },
{ key: 'user.email', label: 'Email Address', type: 'text', order: 2, filterable: false },
];
module.exports = { excludeAttributes, jsonbSchemas, computedAttributes };
+44
View File
@@ -0,0 +1,44 @@
/***********************************************************************************************************************************************************************
* File Name: payments.mdl.js
* Type of Program: Model
* Description: Payment records for tier plan purchases.
* provider_payload JSONB stores all provider-specific data:
* {
* order_id, ← PayPal order ID
* capture_id, ← PayPal capture ID
* payer_id, ← PayPal payer ID
* approval_url, ← PayPal approval URL (stored at order creation)
* checkout: { promo_code, subtotal, discount },
* capture: { ...full PayPal capture response },
* error: { ...PayPal error response if failed },
* cancelled_at, cancelled_by
* }
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 9, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Payments = sequelize.define('Payment', {
payment_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Payment ID', hidden: true, order: 0, filterable: true },
user_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User ID', hidden: true, order: 1, filterable: true },
plan_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Plan ID', hidden: true, order: 2, filterable: true },
tier_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier ID', hidden: true, order: 3, filterable: true },
status: { type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'expired', 'refunded'), allowNull: false, defaultValue: 'pending', label: 'Status', hidden: false, order: 4, filterable: true },
amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Amount', hidden: false, order: 3, filterable: false },
currency: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'USD', label: 'Currency', hidden: false, order: 6, filterable: true },
promo_code: { type: DataTypes.STRING(50), allowNull: true, label: 'Promo Code', hidden: false, order: 7, filterable: true },
discount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, defaultValue: 0.00, label: 'Discount', hidden: false, order: 8, filterable: false },
provider: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'paypal', label: 'Provider', hidden: false, order: 9, filterable: true },
provider_payload: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Provider Payload', hidden: true, order: 10, filterable: false },
paid_at: { type: DataTypes.DATE, allowNull: true, label: 'Paid At', hidden: false, order: 5, filterable: false },
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Updated By' },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Deleted By' },
}, {
tableName: 'payments',
timestamps: true,
paranoid: true,
});
module.exports = mdl_Payments;
+28
View File
@@ -0,0 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: plan_courses.mdl.js
* Type of Program: Model
* Description: Junction table — links courses to a specific tier plan.
* Composite UNIQUE on (plan_id, course_id) only prevents adding
* the same course twice to the same plan — a course may belong
* to many plans simultaneously (Tier Plans v2, silent duplication
* across bundles is intentional; see admin/tiers.controller.js).
* Bundling/display only, not an access-control mechanism — see
* controllers/client/courses.controller.js (hasItemGrant) /
* user_tier_grants for entitlement.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_PlanCourses = sequelize.define('PlanCourse', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: DataTypes.BIGINT, allowNull: false },
course_id: { type: DataTypes.BIGINT, allowNull: false },
}, {
tableName: 'plan_courses',
timestamps: true,
paranoid: false,
});
module.exports = mdl_PlanCourses;
+27
View File
@@ -0,0 +1,27 @@
/***********************************************************************************************************************************************************************
* File Name: plan_lessons.mdl.js
* Type of Program: Model
* Description: Junction table — links standalone Lessons to a specific tier plan.
* Composite UNIQUE on (plan_id, lesson_id) only prevents adding
* the same lesson twice to the same plan — a lesson may belong
* to many plans simultaneously (Tier Plans v2, silent duplication
* across bundles is intentional; see admin/tiers.controller.js).
* Mirrors plan_courses.mdl.js — bundling/display only, not an
* access-control mechanism (see canAccessLesson / user_tier_grants).
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 1, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_PlanLessons = sequelize.define('PlanLesson', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: DataTypes.BIGINT, allowNull: false },
lesson_id: { type: DataTypes.BIGINT, allowNull: false },
}, {
tableName: 'plan_lessons',
timestamps: true,
paranoid: false,
});
module.exports = mdl_PlanLessons;
+27
View File
@@ -0,0 +1,27 @@
/***********************************************************************************************************************************************************************
* File Name: plan_units.mdl.js
* Type of Program: Model
* Description: Junction table — links standalone Units to a specific tier plan.
* Composite UNIQUE on (plan_id, unit_id) only prevents adding the
* same unit twice to the same plan — a unit may belong to many
* plans simultaneously (Tier Plans v2, silent duplication across
* bundles is intentional; see admin/tiers.controller.js).
* Mirrors plan_courses.mdl.js — bundling/display only, not an
* access-control mechanism (see canAccessUnit / user_tier_grants).
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 1, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_PlanUnits = sequelize.define('PlanUnit', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: DataTypes.BIGINT, allowNull: false },
unit_id: { type: DataTypes.BIGINT, allowNull: false },
}, {
tableName: 'plan_units',
timestamps: true,
paranoid: false,
});
module.exports = mdl_PlanUnits;
+111
View File
@@ -0,0 +1,111 @@
const mdl_Users = require('../users/users.mdl');
const mdl_TierCategories = require('./tier_categories.mdl');
const mdl_TierPlans = require('./tier_plans.mdl');
const mdl_UserTiers = require('./user_tiers.mdl');
const mdl_Payments = require('./payments.mdl');
const mdl_PlanCourses = require('./plan_courses.mdl');
const mdl_PlanUnits = require('./plan_units.mdl');
const mdl_PlanLessons = require('./plan_lessons.mdl');
const mdl_UserTierGrants = require('./user_tier_grants.mdl');
const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
const Asset = require('../assets/assets.mdl');
const { Course } = require('../courses/courses.mdl');
const Unit = require('../courses/units.mdl');
const Lesson = require('../courses/lessons.mdl');
// ─── TierCategory ─────────────────────────────────────────────────────────────
mdl_TierCategories.belongsTo(Asset, { foreignKey: 'badge_asset_id', as: 'badgeAsset' });
mdl_TierCategories.hasMany(mdl_TierPlans, { foreignKey: 'tier_category_id', as: 'plans' });
// ─── TierPlans → TierCategory ─────────────────────────────────────────────────
mdl_TierPlans.belongsTo(mdl_TierCategories, { foreignKey: 'tier_category_id', as: 'category' });
// ─── UserTiers ────────────────────────────────────────────────────────────────
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'granted_by', as: 'grantedByUser' });
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'revoked_by', as: 'revokedByUser' });
mdl_Users.hasMany(mdl_UserTiers, { foreignKey: 'user_id', as: 'tiers' });
// ─── Payments ─────────────────────────────────────────────────────────────────
mdl_Payments.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
mdl_Payments.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_Payments.belongsTo(mdl_UserTiers, { foreignKey: 'tier_id', as: 'tier' });
// ─── Plan ↔ Courses ───────────────────────────────────────────────────────────
mdl_TierPlans.belongsToMany(Course, {
through: mdl_PlanCourses,
foreignKey: 'plan_id',
otherKey: 'course_id',
as: 'courses',
});
Course.belongsToMany(mdl_TierPlans, {
through: mdl_PlanCourses,
foreignKey: 'course_id',
otherKey: 'plan_id',
as: 'plans',
});
mdl_PlanCourses.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course' });
Course.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'course_id' });
mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' });
// ─── Plan ↔ Units ─────────────────────────────────────────────────────────────
mdl_TierPlans.belongsToMany(Unit, {
through: mdl_PlanUnits,
foreignKey: 'plan_id',
otherKey: 'unit_id',
as: 'units',
});
Unit.belongsToMany(mdl_TierPlans, {
through: mdl_PlanUnits,
foreignKey: 'unit_id',
otherKey: 'plan_id',
as: 'plans',
});
mdl_PlanUnits.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_PlanUnits.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' });
Unit.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'unit_id' });
mdl_TierPlans.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'plan_id' });
// ─── Plan ↔ Lessons ───────────────────────────────────────────────────────────
mdl_TierPlans.belongsToMany(Lesson, {
through: mdl_PlanLessons,
foreignKey: 'plan_id',
otherKey: 'lesson_id',
as: 'lessons',
});
Lesson.belongsToMany(mdl_TierPlans, {
through: mdl_PlanLessons,
foreignKey: 'lesson_id',
otherKey: 'plan_id',
as: 'plans',
});
mdl_PlanLessons.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_PlanLessons.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' });
Lesson.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'lesson_id' });
mdl_TierPlans.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'plan_id' });
// ─── UserTier → Plan ──────────────────────────────────────────────────────────
mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' });
// ─── UserTier → UserTierGrants (item-specific entitlement snapshot) ──────────
mdl_UserTiers.hasMany(mdl_UserTierGrants, { foreignKey: 'user_tier_id', as: 'grants' });
mdl_UserTierGrants.belongsTo(mdl_UserTiers, { foreignKey: 'user_tier_id', as: 'userTier' });
mdl_UserTierGrants.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
mdl_UserTierGrants.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
// ─── SystemBadge → Asset ─────────────────────────────────────────────────────
mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' });
module.exports = {
mdl_TierCategories,
mdl_TierPlans,
mdl_UserTiers,
mdl_Payments,
mdl_PlanCourses,
mdl_PlanUnits,
mdl_PlanLessons,
mdl_SystemBadges,
mdl_UserTierGrants,
};
@@ -0,0 +1,23 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_TierCategories = sequelize.define('TierCategory', {
tier_category_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
slug: { type: DataTypes.STRING(50), allowNull: false, unique: true, label: 'Slug' },
name: { type: DataTypes.STRING(100), allowNull: false, label: 'Name' },
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' },
rank: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Rank' },
badge_asset_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Badge Asset' },
badge_icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Badge Icon' },
badge_label: { type: DataTypes.STRING(100), allowNull: true, label: 'Badge Label' },
color: { type: DataTypes.STRING(30), allowNull: false, defaultValue: 'purple', label: 'Color' },
is_default: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'Default' },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' },
is_special: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'Special' },
}, {
tableName: 'tier_categories',
timestamps: true,
paranoid: false,
});
module.exports = mdl_TierCategories;
@@ -0,0 +1,71 @@
/***********************************************************************************************************************************************************************
* File Name: tier_plans.attributes.js
* Type of Program: Attributes
* Description: Exclude list, display attributes, and computed fields for tier_plans.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/
const excludeAttributes = [
"tier_category_id",
"description",
];
const jsonbSchemas = {}; // no JSONB columns on this model
const computedAttributes = [
{
key: "courseCount", label: "Courses", type: "number", order: 100, filterable: false,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_courses"
INNER JOIN "courses" ON "courses"."course_id" = "plan_courses"."course_id"
WHERE "plan_courses"."plan_id" = "TierPlan"."plan_id"
AND "courses"."deletedAt" IS NULL
)`,
},
{
key: "unitCount", label: "Units", type: "number", order: 101, filterable: false,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_units"
INNER JOIN "units" ON "units"."unit_id" = "plan_units"."unit_id"
WHERE "plan_units"."plan_id" = "TierPlan"."plan_id"
AND "units"."deletedAt" IS NULL
)`,
},
{
key: "lessonCount", label: "Lessons", type: "number", order: 102, filterable: false,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_lessons"
INNER JOIN "lessons" ON "lessons"."lesson_id" = "plan_lessons"."lesson_id"
WHERE "plan_lessons"."plan_id" = "TierPlan"."plan_id"
AND "lessons"."deletedAt" IS NULL
)`,
},
{
key: "bundleCount", label: "Bundles", type: "number", order: 103, filterable: false,
literal: `(
(SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_courses"
INNER JOIN "courses" ON "courses"."course_id" = "plan_courses"."course_id"
WHERE "plan_courses"."plan_id" = "TierPlan"."plan_id"
AND "courses"."deletedAt" IS NULL)
+
(SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_units"
INNER JOIN "units" ON "units"."unit_id" = "plan_units"."unit_id"
WHERE "plan_units"."plan_id" = "TierPlan"."plan_id"
AND "units"."deletedAt" IS NULL)
+
(SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_lessons"
INNER JOIN "lessons" ON "lessons"."lesson_id" = "plan_lessons"."lesson_id"
WHERE "plan_lessons"."plan_id" = "TierPlan"."plan_id"
AND "lessons"."deletedAt" IS NULL)
)`,
},
];
module.exports = { excludeAttributes, jsonbSchemas, computedAttributes };
+35
View File
@@ -0,0 +1,35 @@
/***********************************************************************************************************************************************************************
* File Name: tier_plans.mdl.js
* Type of Program: Model
* Description: Sequelize model for the `tier_plans` table.
* Catalog of fixed plans (premium/exclusive) with duration and price.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_TierPlans = sequelize.define('TierPlan', {
plan_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Plan ID' },
tier_category_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier Category' },
tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' },
label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' },
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' },
features: { type: DataTypes.JSONB, allowNull: true, label: 'Features', comment: 'Array of {text} — admin-authored "what\'s included" bullets shown on the client Plans page.' },
duration_days: { type: DataTypes.FLOAT, allowNull: false, label: 'Duration (Days)' },
duration_unit: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'day', label: 'Duration Unit' },
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' },
is_recommended: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'Recommended', comment: 'Highlights this plan with the "Recommended for you" banner on the client Plans page.' },
status: { type: DataTypes.ENUM('draft', 'published'), allowNull: false, defaultValue: 'draft', label: 'Status', hidden: false, filterable: true },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: 'tier_plans',
timestamps: true,
paranoid: true,
});
module.exports = mdl_TierPlans;
@@ -0,0 +1,30 @@
/***********************************************************************************************************************************************************************
* File Name: user_tier_grants.mdl.js
* Type of Program: Model
* Description: Purchase-time snapshot of the exact courses/units/lessons a
* Tier Plan purchase granted. Replaces live re-resolution off
* plan_courses/plan_units/plan_lessons at access-check time, so
* editing a plan's bundle later no longer retroactively changes
* what past purchasers can access. See hasItemGrant() in
* controllers/client/courses.controller.js.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 4, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_UserTierGrants = sequelize.define('UserTierGrant', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_tier_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User Tier ID' },
user_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User ID' },
plan_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Plan ID' },
item_type: { type: DataTypes.STRING(10), allowNull: false, label: 'Item Type' },
item_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Item ID' },
granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At' },
}, {
tableName: 'user_tier_grants',
timestamps: true,
paranoid: false,
});
module.exports = mdl_UserTierGrants;
+31
View File
@@ -0,0 +1,31 @@
/***********************************************************************************************************************************************************************
* File Name: user_tiers.mdl.js
* Type of Program: Model
* Description: Sequelize model for the `user_tiers` table.
* Full history of tier grants per user.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_UserTiers = sequelize.define('UserTier', {
tier_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Tier ID' },
user_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User ID' },
tier: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'free', label: 'Tier' },
status: { type: DataTypes.ENUM('active', 'expired', 'revoked'), allowNull: false, defaultValue: 'active', label: 'Status' },
starts_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Starts At' },
expires_at: { type: DataTypes.DATE, allowNull: true, label: 'Expires At' },
plan_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Plan ID' },
granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By' },
revoked_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Revoked By' },
revoked_at: { type: DataTypes.DATE, allowNull: true, label: 'Revoked At' },
notes: { type: DataTypes.TEXT, allowNull: true, label: 'Notes' },
}, {
tableName: 'user_tiers',
timestamps: true,
paranoid: false,
});
module.exports = mdl_UserTiers;
@@ -0,0 +1,32 @@
/***********************************************************************************************************************************************************************
* File Name: achievement_definitions.mdl.js
* Type of Program: Model
* Description: Admin-managed catalog of achievements (badges and milestones).
* Replaces the old static ACHIEVEMENT_REGISTRY in data/achievements.data.js.
* `is_system` rows are the built-in keys referenced by name in
* services/achievements.service.js's trigger functions — protected
* from deletion/key-rename by the admin controller.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_AchievementDefinitions = sequelize.define('AchievementDefinition', {
achievement_definition_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
key: { type: DataTypes.STRING(100), allowNull: false, unique: true, label: 'Key', hidden: false, order: 1, filterable: true },
type: { type: DataTypes.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge', label: 'Type', hidden: false, order: 2, filterable: true },
label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false },
icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Icon', hidden: false, order: 5, filterable: false },
trigger: { type: DataTypes.STRING(30), allowNull: true, label: 'Trigger', hidden: false, order: 6, filterable: true },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active', hidden: false, order: 7, filterable: true },
is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 8, filterable: true },
}, {
tableName: 'achievement_definitions',
timestamps: true,
paranoid: false,
});
module.exports = mdl_AchievementDefinitions;
+39
View File
@@ -0,0 +1,39 @@
/***********************************************************************************************************************************************************************
* File Name: achievements.mdl.js
* Type of Program: Model
* Description: User achievements — badges and milestones.
* Unique per user + key (one achievement of each type per user).
*
* Built-in keys:
* Badge:
* early_access — registered before Dec 31, 2026
* premium_first_time — first premium tier purchase
* exclusive_first_time — first exclusive tier purchase
* Milestone:
* course_completed — completed first course
* (extend as needed)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 11, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Achievements = sequelize.define('Achievement', {
achievement_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Achievement ID', hidden: true, order: 0 },
user_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User ID', hidden: true, order: 0 },
type: { type: DataTypes.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge', label: 'Type', hidden: false, order: 1, filterable: true },
key: { type: DataTypes.STRING(100), allowNull: false, label: 'Key', hidden: false, order: 2, filterable: true },
label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false },
icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Icon', hidden: false, order: 5, filterable: false },
granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By', hidden: false, order: 5, filterable: false },
granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At', hidden: false, order: 6, filterable: false },
metadata: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Metadata', hidden: true, order: 0 },
}, {
tableName: 'achievements',
timestamps: true,
paranoid: false,
});
module.exports = mdl_Achievements;
@@ -0,0 +1,58 @@
/***********************************************************************************************************************************************************************
* File Name: trusted_devices.mdl.js
* Type of Program: Model
* Description: Sequelize model for the `trusted_devices` table.
* One rolling row per (user_id, fingerprint_hash) — lets a login
* from an already-verified device skip the OTP gate until the
* trust window lapses or is explicitly revoked.
* Has a Many-to-One relationship with Users and UserSessions.
* Author: Kenneth Obsequio
* Date Created: Jul. 5, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('./users.mdl');
const mdl_UserSessions = require('./user_sessions.mdl');
const mdl_TrustedDevices = sequelize.define('TrustedDevices', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: mdl_Users, key: 'user_id' },
},
// SHA-256 of the opaque token stored in the `device_trust` cookie.
device_token_hash: { type: DataTypes.TEXT, allowNull: false },
// SHA-256 of `browser|os|device` parsed from the User-Agent header.
fingerprint_hash: { type: DataTypes.TEXT, allowNull: false },
// Most recent user_sessions row minted for this device — lets a single
// session termination revoke just this device's trust.
last_session_id: {
type: DataTypes.BIGINT,
allowNull: true,
references: { model: mdl_UserSessions, key: 'session_id' },
},
expires_at: { type: DataTypes.DATE, allowNull: false },
revoked_at: { type: DataTypes.DATE, allowNull: true },
// ── Audit trails ────────────────────────────────────────────────────────────
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: 'trusted_devices',
timestamps: true,
paranoid: true, // enables soft delete — sets deleted_at instead of DELETE
});
// Associations
mdl_TrustedDevices.belongsTo(mdl_Users, { foreignKey: 'user_id' });
mdl_Users.hasMany(mdl_TrustedDevices, { foreignKey: 'user_id' });
mdl_TrustedDevices.belongsTo(mdl_UserSessions, { foreignKey: 'last_session_id' });
module.exports = mdl_TrustedDevices;
@@ -0,0 +1,30 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('./users.mdl');
const mdl_UserActivity = sequelize.define('UserActivity', {
activity_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: DataTypes.BIGINT, allowNull: false, references: { model: mdl_Users, key: 'user_id' } },
session_id: { type: DataTypes.BIGINT, allowNull: true },
action: { type: DataTypes.STRING(100), allowNull: false },
entity_type: { type: DataTypes.STRING(50), allowNull: true },
entity_id: { type: DataTypes.BIGINT, allowNull: true },
/**
* details JSONB — free-form context per action, e.g.:
* login : { session_id, reg_type }
* deactivate_user : { target_email }
* lesson_read : { lesson_uuid, status }
* submit_task : { task_id }
* set_user_status : { is_active }
*/
details: { type: DataTypes.JSONB, allowNull: true },
created_at: { type: DataTypes.DATE, allowNull: true, defaultValue: DataTypes.NOW },
}, {
tableName: 'user_activity',
timestamps: false,
});
mdl_UserActivity.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
mdl_Users.hasMany(mdl_UserActivity, { foreignKey: 'user_id', as: 'activities' });
module.exports = mdl_UserActivity;
+41
View File
@@ -0,0 +1,41 @@
/***********************************************************************************************************************************************************************
* File Name: user_bans.mdl.js
* Type of Program: Model
* Description: Sequelize model for the `user_bans` table.
* Stores ban/unban audit records for policy enforcement actions.
* Distinct from deactivation (account lifecycle) — bans track WHY,
* WHO banned, duration, and lift history.
* Author: Kenneth Obsequio
* Date Created: Jun. 27, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('./users.mdl');
const mdl_UserBans = sequelize.define('UserBan', {
ban_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: DataTypes.BIGINT, allowNull: false, references: { model: mdl_Users, key: 'user_id' } },
banned_by: { type: DataTypes.BIGINT, allowNull: false, references: { model: mdl_Users, key: 'user_id' } },
reason: { type: DataTypes.TEXT, allowNull: false },
ban_type: { type: DataTypes.ENUM('temporary', 'permanent'), allowNull: false },
banned_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
expires_at: { type: DataTypes.DATE, allowNull: true },
is_lifted: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
lifted_at: { type: DataTypes.DATE, allowNull: true },
lifted_by: { type: DataTypes.BIGINT, allowNull: true, references: { model: mdl_Users, key: 'user_id' } },
lift_reason: { type: DataTypes.TEXT, allowNull: true },
}, {
tableName: 'user_bans',
timestamps: true,
});
// ─── Associations ──────────────────────────────────────────────────────────────
mdl_UserBans.belongsTo(mdl_Users, { as: 'user', foreignKey: 'user_id' });
mdl_UserBans.belongsTo(mdl_Users, { as: 'banner', foreignKey: 'banned_by' });
mdl_UserBans.belongsTo(mdl_Users, { as: 'lifter', foreignKey: 'lifted_by' });
mdl_Users.hasMany(mdl_UserBans, { as: 'bans', foreignKey: 'user_id' });
module.exports = mdl_UserBans;
@@ -0,0 +1,38 @@
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: "memberCount",
label: "Members",
type: "number",
order: 5,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "user_group_members"
JOIN "users" ON "users"."user_id" = "user_group_members"."user_id"
WHERE "user_group_members"."group_id" = "UserGroup"."group_id"
AND "user_group_members"."deletedAt" IS NULL
AND "users"."deletedAt" IS NULL
)`,
},
];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes };
+86
View File
@@ -0,0 +1,86 @@
/***********************************************************************************************************************************************************************
* File Name: user_groups.mdl.js
* Type of Program: Model
* Description: Sequelize models for `user_groups` and `user_group_members` tables.
* Implements a Many-to-Many self-contained group system.
* Groups are used as named permission bundles.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* Change History:
* DATE AUTHOR LOG DESCRIPTION
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
* May 23, 2026 rgrgogu 002 Added group_code — unique invite code for self-registration
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { mdl_UserGroups, mdl_UserGroupMembers } = require('./models/user_groups.mdl');
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('./users.mdl');
// ─── UserGroups ────────────────────────────────────────────────────────────────
const mdl_UserGroups = sequelize.define('UserGroup', {
group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Group ID", order: 1, hidden: true },
name: { type: DataTypes.STRING(50), allowNull: false, label: "Group Name", order: 2 },
/**
* group_code — unique, human-readable invite code distributed to users.
* Used in the self-registration URL: /register?group_code=<value>
* Automatically uppercased via a beforeValidate hook below.
* Example: "SALES-2025", "ONBOARD-Q1"
*/
group_code: { type: DataTypes.STRING(50), allowNull: true, unique: true, label: "Group Code", order: 3 },
description:{ type: DataTypes.TEXT, label: "Description", order: 4, hidden: true },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 5 },
// ── Audit trails ────────────────────────────────────────────────────────────
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: 'user_groups',
timestamps: true,
paranoid: true,
hooks: {
// Always store group_code in uppercase to make lookups case-insensitive
beforeValidate: (group) => {
if (group.group_code) group.group_code = group.group_code.toUpperCase().trim();
},
},
});
// ─── Junction: UserGroupMembers ────────────────────────────────────────────────
const mdl_UserGroupMembers = sequelize.define('UserGroupMember', {
group_id: { type: DataTypes.BIGINT, primaryKey: true },
user_id: { type: DataTypes.BIGINT, primaryKey: true },
joined_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
// ── Audit trails ────────────────────────────────────────────────────────────
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: 'user_group_members',
timestamps: true,
paranoid: true,
});
// ─── Associations ──────────────────────────────────────────────────────────────
mdl_Users.belongsToMany(mdl_UserGroups, { through: mdl_UserGroupMembers, foreignKey: 'user_id', otherKey: 'group_id', as: 'groups' });
mdl_UserGroups.belongsToMany(mdl_Users, { through: mdl_UserGroupMembers, foreignKey: 'group_id', otherKey: 'user_id', as: 'members' });
mdl_UserGroups.hasMany(mdl_UserGroupMembers, { foreignKey: 'group_id' });
mdl_UserGroupMembers.belongsTo(mdl_UserGroups, { foreignKey: 'group_id' });
mdl_Users.hasMany(mdl_UserGroupMembers, { foreignKey: 'user_id' });
mdl_UserGroupMembers.belongsTo(mdl_Users, { foreignKey: 'user_id' });
mdl_UserGroups.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' });
mdl_UserGroups.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' });
mdl_UserGroups.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' });
mdl_Users.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' });
mdl_Users.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' });
mdl_Users.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' });
module.exports = { mdl_UserGroups, mdl_UserGroupMembers };
@@ -0,0 +1,60 @@
/***********************************************************************************************************************************************************************
* File Name: user_sessions.mdl.js
* Type of Program: Model
* Description: Sequelize model for the `user_sessions` table.
* Captures login and logout audit data (IP, geo, device) as JSONB.
* Has a Many-to-One relationship with Users.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* Change History:
* DATE AUTHOR LOG DESCRIPTION
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('./users.mdl');
const mdl_UserSessions = sequelize.define('UserSessions', {
session_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: mdl_Users, key: 'user_id' },
},
/**
* login_info / logout_info JSONB:
* {
* date: ISO string,
* ip_address: string,
* country: string,
* region: string,
* city: string,
* lat: number,
* long: number,
* device_info: { ua, browser, os, device }
* forced_by: user_id
* }
*/
login_info: { type: DataTypes.JSONB, allowNull: true },
logout_info: { type: DataTypes.JSONB, allowNull: true },
// Store the refresh-token hash so we can invalidate individual sessions
refresh_token_hash: { type: DataTypes.TEXT, allowNull: true },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
// ── Audit trails ────────────────────────────────────────────────────────────
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: 'user_sessions',
timestamps: true,
paranoid: true, // enables soft delete — sets deleted_at instead of DELETE
});
// Associations
mdl_UserSessions.belongsTo(mdl_Users, { foreignKey: 'user_id' });
mdl_Users.hasMany(mdl_UserSessions, { foreignKey: 'user_id' });
module.exports = mdl_UserSessions;
+59
View File
@@ -0,0 +1,59 @@
const excludeAttributes = [
"password", "otp_code", "otp_expires_at", 'must_change_password', 'password_expires_at', "needs_intro",
"personal_info.name.given_name",
"personal_info.name.middle_name",
"personal_info.name.last_name",
"personal_info.name.extension_name",
"personal_info.addresses[]",
"personal_info.addresses[].city",
"personal_info.addresses[].country",
"personal_info.addresses[].street",
"personal_info.addresses[].zip",
"personal_info.addresses[].address_type",
"personal_info.addresses[].state",
"personal_info.phone_number[]",
"personal_info.phone_number[].country_code",
"personal_info.phone_number[].number",
"personal_info.phone_number[].phone_type",
];
const jsonbSchemas = {
personal_info: {
name: {
full_name: { type: "text", label: "Full Name", order: 2 }, // ← slot 2
},
date_of_birth: { type: "date", label: "Date of Birth", order: 4 },
occupation: { type: "text", label: "Occupation", order: 5 },
// addresses: {
// full_address: { type: "text", label: "Full Address" },
// },
// phone_number: {
// full_number: { type: "text", label: "Phone Number" },
// },
},
};
// 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: "groups",
label: "Groups",
type: "array", // tells the paginator this is a pre-joined association array
order: 5,
filterable: true, // handled specially in the controller — see extractGroupsFilter()
},
];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes };
+68
View File
@@ -0,0 +1,68 @@
/***********************************************************************************************************************************************************************
* File Name: users.mdl.js
* Type of Program: Model
* Description: Sequelize model for the `users` table.
* Stores authentication credentials, account flags, registration type,
* role/account type, and a flexible JSONB personal_info column.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* Change History:
* DATE AUTHOR LOG DESCRIPTION
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = sequelize.define('User', {
user_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "User ID", order: 1, hidden: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, label: "Email Address", validate: { isEmail: true }, order: 3 },
password: { type: DataTypes.TEXT, label: "Password" },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active", order: 6 },
is_verified: { type: DataTypes.BOOLEAN, defaultValue: false, label: "Verified", order: 7 },
reg_type: { type: DataTypes.ENUM('google', 'system'), defaultValue: 'system', label: "Registration Type", order: 8 },
/**
* acc_type drives RBAC:
* - "user" → Client endpoints only
* - "staff" → Client + Staff endpoints
* - "admin" → All endpoints
*/
acc_type: { type: DataTypes.ENUM('admin', 'staff', 'user'), defaultValue: 'user', label: "Account Type", order: 9 },
needs_intro: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Needs Intro" },
/**
* personal_info JSONB structure:
* {
* name: { given_name, middle_name, last_name, extension_name, full_name },
* occupation: string,
* addresses: [{ street, city, state, zip, country, address_type, full_address }],
* phone_number: [{ number, country_code, phone_type, full_number }],
* date_of_birth: date,
* avatar: { mime_type, name, size, url, uuid },
* album: [{ uuid, file_url, original_name, uploaded_at, order_index }]
* }
*/
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 },
// ── Ban state ────────────────────────────────────────────────────────────────
is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" },
ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" },
// ── Password policy ─────────────────────────────────────────────────────────
must_change_password: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Must Change Password" },
password_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Password Expires At" },
// OTP fields (stored temporarily during verification flow)
otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" },
otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" },
// ── Audit trails ────────────────────────────────────────────────────────────
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: 'users',
timestamps: true,
paranoid: true, // enables soft delete — sets deleted_at instead of DELETE
});
module.exports = mdl_Users;