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