diff --git a/database/migrations/20260101000008-create-course-junction-tables.js b/database/migrations/20260101000008-create-course-junction-tables.js
deleted file mode 100644
index 34f1608..0000000
--- a/database/migrations/20260101000008-create-course-junction-tables.js
+++ /dev/null
@@ -1,35 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('course_products', {
- id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
- product_id: { type: Sequelize.BIGINT, allowNull: false },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
-
- await queryInterface.createTable('course_roles', {
- id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
- role_id: { type: Sequelize.BIGINT, allowNull: false },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
-
- await queryInterface.createTable('course_product_categories', {
- id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
- category_id: { type: Sequelize.BIGINT, allowNull: false },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('course_product_categories');
- await queryInterface.dropTable('course_roles');
- await queryInterface.dropTable('course_products');
- },
-};
diff --git a/database/migrations/20260101000019-create-quiz-attempts.js b/database/migrations/20260101000019-create-quiz-attempts.js
deleted file mode 100644
index a834667..0000000
--- a/database/migrations/20260101000019-create-quiz-attempts.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('quiz_attempts', {
- attempt_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
- user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
- quiz_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'unit_quizzes', key: 'quiz_id' }, onDelete: 'SET NULL' },
- assessment_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'SET NULL' },
- answers: { type: Sequelize.JSONB, allowNull: false, defaultValue: {} },
- total_points: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
- earned_points: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
- score: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
- passed: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
-
- await queryInterface.addIndex('quiz_attempts', ['user_id']);
- await queryInterface.addIndex('quiz_attempts', ['quiz_id']);
- await queryInterface.addIndex('quiz_attempts', ['assessment_id']);
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('quiz_attempts');
- },
-};
diff --git a/database/migrations/20260101000020-create-tier-plans.js b/database/migrations/20260101000020-create-tier-plans.js
deleted file mode 100644
index feb3a64..0000000
--- a/database/migrations/20260101000020-create-tier-plans.js
+++ /dev/null
@@ -1,22 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('tier_plans', {
- plan_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- tier: { type: Sequelize.ENUM('premium', 'exclusive'), allowNull: false },
- label: { type: Sequelize.STRING(100), allowNull: false },
- duration_days: { type: Sequelize.INTEGER, allowNull: false },
- price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
- currency: { type: Sequelize.CHAR(3), allowNull: false, defaultValue: 'USD' },
- is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- deletedAt: { type: Sequelize.DATE, allowNull: true },
- });
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('tier_plans');
- },
-};
diff --git a/database/migrations/20260101000027-create-tasks.js b/database/migrations/20260101000027-create-tasks.js
deleted file mode 100644
index f5c14a0..0000000
--- a/database/migrations/20260101000027-create-tasks.js
+++ /dev/null
@@ -1,27 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('tasks', {
- task_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
- task_list_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, onDelete: 'CASCADE' },
- name: { type: Sequelize.STRING, allowNull: false },
- description: { type: Sequelize.TEXT, allowNull: true },
- deadline: { type: Sequelize.DATE, allowNull: true },
- status: { type: Sequelize.ENUM('pending', 'in_progress', 'completed', 'overdue'), defaultValue: 'pending', allowNull: false },
- createdBy: { type: Sequelize.INTEGER, allowNull: true },
- updatedBy: { type: Sequelize.INTEGER, allowNull: true },
- deletedBy: { type: Sequelize.INTEGER, allowNull: true },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- deletedAt: { type: Sequelize.DATE, allowNull: true },
- });
-
- await queryInterface.addIndex('tasks', ['task_list_id']);
- await queryInterface.addIndex('tasks', ['status']);
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('tasks');
- },
-};
diff --git a/database/migrations/20260101000033-create-admin-notifications.js b/database/migrations/20260101000033-create-admin-notifications.js
deleted file mode 100644
index 08e6b3e..0000000
--- a/database/migrations/20260101000033-create-admin-notifications.js
+++ /dev/null
@@ -1,25 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('admin_notifications', {
- notification_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- type: { type: Sequelize.STRING(64), allowNull: false },
- title: { type: Sequelize.STRING(255), allowNull: false },
- message: { type: Sequelize.TEXT, allowNull: false },
- data: { type: Sequelize.JSONB, allowNull: true, defaultValue: null },
- seen: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
- seen_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
-
- await queryInterface.addIndex('admin_notifications', { fields: ['seen'], name: 'idx_an_seen' });
- await queryInterface.addIndex('admin_notifications', { fields: ['type'], name: 'idx_an_type' });
- await queryInterface.addIndex('admin_notifications', { fields: ['createdAt'], name: 'idx_an_created_at' });
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('admin_notifications');
- },
-};
diff --git a/database/migrations/20260101000034-create-user-notifications.js b/database/migrations/20260101000034-create-user-notifications.js
deleted file mode 100644
index 168826e..0000000
--- a/database/migrations/20260101000034-create-user-notifications.js
+++ /dev/null
@@ -1,27 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('user_notifications', {
- notification_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
- type: { type: Sequelize.STRING(64), allowNull: false },
- title: { type: Sequelize.STRING(255), allowNull: false },
- message: { type: Sequelize.TEXT, allowNull: false },
- data: { type: Sequelize.JSONB, allowNull: true, defaultValue: null },
- seen: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
- seen_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
-
- await queryInterface.addIndex('user_notifications', { fields: ['user_id'], name: 'idx_un_user_id' });
- await queryInterface.addIndex('user_notifications', { fields: ['user_id', 'seen'], name: 'idx_un_seen' });
- await queryInterface.addIndex('user_notifications', { fields: ['type'], name: 'idx_un_type' });
- await queryInterface.addIndex('user_notifications', { fields: ['createdAt'], name: 'idx_un_created_at' });
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('user_notifications');
- },
-};
diff --git a/database/migrations/20260101000038-add-fks-to-course-junction-tables.js b/database/migrations/20260101000038-add-fks-to-course-junction-tables.js
deleted file mode 100644
index ad899a4..0000000
--- a/database/migrations/20260101000038-add-fks-to-course-junction-tables.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict';
-
-// Wires up the bare product_id and category_id columns that were left
-// as plain integers in migration 000008.
-module.exports = {
- async up(queryInterface) {
- await queryInterface.addConstraint('course_products', {
- fields: ['product_id'],
- type: 'foreign key',
- name: 'fk_course_products_product_id',
- references: { table: 'products', field: 'id' },
- onDelete: 'CASCADE',
- });
-
- await queryInterface.addConstraint('course_product_categories', {
- fields: ['category_id'],
- type: 'foreign key',
- name: 'fk_course_product_categories_category_id',
- references: { table: 'categories', field: 'id' },
- onDelete: 'CASCADE',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeConstraint('course_products', 'fk_course_products_product_id');
- await queryInterface.removeConstraint('course_product_categories', 'fk_course_product_categories_category_id');
- },
-};
diff --git a/database/migrations/20260101000039-drop-unused-tables.js b/database/migrations/20260101000039-drop-unused-tables.js
deleted file mode 100644
index 87d74c5..0000000
--- a/database/migrations/20260101000039-drop-unused-tables.js
+++ /dev/null
@@ -1,31 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.dropTable('file_uploads', { cascade: true });
- await queryInterface.dropTable('course_roles', { cascade: true });
- await queryInterface.dropTable('course_products', { cascade: true });
- },
-
- async down(queryInterface, Sequelize) {
- await queryInterface.createTable('file_uploads', {
- id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
- await queryInterface.createTable('course_roles', {
- id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- course_id: { type: Sequelize.BIGINT, allowNull: false },
- role_id: { type: Sequelize.BIGINT, allowNull: false },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
- await queryInterface.createTable('course_products', {
- id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- course_id: { type: Sequelize.BIGINT, allowNull: false },
- product_id: { type: Sequelize.BIGINT, allowNull: false },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
- },
-};
diff --git a/database/migrations/20260101000041-add-attempt-number-passing-score-course-id-to-quiz-attempts.js b/database/migrations/20260101000041-add-attempt-number-passing-score-course-id-to-quiz-attempts.js
deleted file mode 100644
index 3aa8423..0000000
--- a/database/migrations/20260101000041-add-attempt-number-passing-score-course-id-to-quiz-attempts.js
+++ /dev/null
@@ -1,35 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('quiz_attempts', 'attempt_number', {
- type: Sequelize.INTEGER,
- allowNull: false,
- defaultValue: 1,
- comment: '1-based counter per user + quiz (or user + assessment). Derived at insert time from prior attempt count.',
- });
-
- await queryInterface.addColumn('quiz_attempts', 'passing_score', {
- type: Sequelize.INTEGER,
- allowNull: true,
- comment: 'Snapshot of the required passing score at the time of submission.',
- });
-
- await queryInterface.addColumn('quiz_attempts', 'course_id', {
- type: Sequelize.BIGINT,
- allowNull: true,
- references: { model: 'courses', key: 'course_id' },
- onDelete: 'SET NULL',
- comment: 'Denormalized course reference for fast per-course attempt queries.',
- });
-
- await queryInterface.addIndex('quiz_attempts', { fields: ['course_id'], name: 'idx_qa_course_id' });
- },
-
- async down(queryInterface) {
- await queryInterface.removeIndex('quiz_attempts', 'idx_qa_course_id');
- await queryInterface.removeColumn('quiz_attempts', 'course_id');
- await queryInterface.removeColumn('quiz_attempts', 'passing_score');
- await queryInterface.removeColumn('quiz_attempts', 'attempt_number');
- },
-};
diff --git a/database/migrations/20260101000047-create-announcement-junction-tables.js b/database/migrations/20260101000047-create-announcement-junction-tables.js
deleted file mode 100644
index b87fd6a..0000000
--- a/database/migrations/20260101000047-create-announcement-junction-tables.js
+++ /dev/null
@@ -1,100 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('announcement_courses', {
- id: {
- type: Sequelize.BIGINT,
- autoIncrement: true,
- primaryKey: true,
- },
- announcement_id: {
- type: Sequelize.BIGINT,
- allowNull: false,
- references: { model: 'announcements', key: 'id' },
- onDelete: 'CASCADE',
- },
- course_id: {
- type: Sequelize.BIGINT,
- allowNull: false,
- references: { model: 'courses', key: 'course_id' },
- onDelete: 'CASCADE',
- },
- });
-
- await queryInterface.addConstraint('announcement_courses', {
- fields: ['announcement_id', 'course_id'],
- type: 'unique',
- name: 'uq_announcement_courses',
- });
-
- await queryInterface.addIndex('announcement_courses', { fields: ['announcement_id'], name: 'idx_announcement_courses_ann_id' });
- await queryInterface.addIndex('announcement_courses', { fields: ['course_id'], name: 'idx_announcement_courses_course_id' });
-
- await queryInterface.createTable('announcement_roles', {
- id: {
- type: Sequelize.BIGINT,
- autoIncrement: true,
- primaryKey: true,
- },
- announcement_id: {
- type: Sequelize.BIGINT,
- allowNull: false,
- references: { model: 'announcements', key: 'id' },
- onDelete: 'CASCADE',
- },
- role: {
- type: Sequelize.STRING(50),
- allowNull: false,
- },
- });
-
- await queryInterface.addConstraint('announcement_roles', {
- fields: ['announcement_id', 'role'],
- type: 'unique',
- name: 'uq_announcement_roles',
- });
-
- await queryInterface.addIndex('announcement_roles', { fields: ['announcement_id'], name: 'idx_announcement_roles_ann_id' });
-
- await queryInterface.createTable('announcement_reads', {
- id: {
- type: Sequelize.BIGINT,
- autoIncrement: true,
- primaryKey: true,
- },
- announcement_id: {
- type: Sequelize.BIGINT,
- allowNull: false,
- references: { model: 'announcements', key: 'id' },
- onDelete: 'CASCADE',
- },
- user_id: {
- type: Sequelize.BIGINT,
- allowNull: false,
- references: { model: 'users', key: 'user_id' },
- onDelete: 'CASCADE',
- },
- read_at: {
- type: Sequelize.DATE,
- allowNull: false,
- defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
- },
- });
-
- await queryInterface.addConstraint('announcement_reads', {
- fields: ['announcement_id', 'user_id'],
- type: 'unique',
- name: 'uq_announcement_reads',
- });
-
- await queryInterface.addIndex('announcement_reads', { fields: ['announcement_id'], name: 'idx_announcement_reads_ann_id' });
- await queryInterface.addIndex('announcement_reads', { fields: ['user_id'], name: 'idx_announcement_reads_user_id' });
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('announcement_reads');
- await queryInterface.dropTable('announcement_roles');
- await queryInterface.dropTable('announcement_courses');
- },
-};
diff --git a/database/migrations/20260101000050-add-thumbnail-storage-key-to-assets.js b/database/migrations/20260101000050-add-thumbnail-storage-key-to-assets.js
deleted file mode 100644
index cc0a7a4..0000000
--- a/database/migrations/20260101000050-add-thumbnail-storage-key-to-assets.js
+++ /dev/null
@@ -1,14 +0,0 @@
-'use strict';
-
-module.exports = {
- up: async (queryInterface, Sequelize) => {
- await queryInterface.addColumn('assets', 'thumbnail_storage_key', {
- type: Sequelize.STRING(512),
- allowNull: true,
- after: 'thumbnail_url',
- });
- },
- down: async (queryInterface) => {
- await queryInterface.removeColumn('assets', 'thumbnail_storage_key');
- },
-};
diff --git a/database/migrations/20260101000051-add-needs-intro-to-users.js b/database/migrations/20260101000051-add-needs-intro-to-users.js
deleted file mode 100644
index d0c8e8d..0000000
--- a/database/migrations/20260101000051-add-needs-intro-to-users.js
+++ /dev/null
@@ -1,15 +0,0 @@
-'use strict';
-
-module.exports = {
- up: async (queryInterface, Sequelize) => {
- await queryInterface.addColumn('users', 'needs_intro', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: false, // existing users skip intro
- after: 'acc_type',
- });
- },
- down: async (queryInterface) => {
- await queryInterface.removeColumn('users', 'needs_intro');
- },
-};
diff --git a/database/migrations/20260101000052-create-assessment-sessions.js b/database/migrations/20260101000052-create-assessment-sessions.js
deleted file mode 100644
index 02b8da3..0000000
--- a/database/migrations/20260101000052-create-assessment-sessions.js
+++ /dev/null
@@ -1,27 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('assessment_sessions', {
- session_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true, allowNull: false },
- user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
- assessment_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'CASCADE' },
- course_id: { type: Sequelize.BIGINT, allowNull: true },
- started_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
- expires_at: { type: Sequelize.DATE, allowNull: true },
- status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'in_progress' },
- attempt_id: { type: Sequelize.BIGINT, allowNull: true },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
-
- await queryInterface.addIndex('assessment_sessions', ['user_id'], { name: 'idx_as_user_id' });
- await queryInterface.addIndex('assessment_sessions', ['assessment_id'], { name: 'idx_as_assessment_id' });
- await queryInterface.addIndex('assessment_sessions', ['status'], { name: 'idx_as_status' });
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('assessment_sessions');
- },
-};
diff --git a/database/migrations/20260101000053-drop-session-cols-from-quiz-attempts.js b/database/migrations/20260101000053-drop-session-cols-from-quiz-attempts.js
deleted file mode 100644
index 7f8df48..0000000
--- a/database/migrations/20260101000053-drop-session-cols-from-quiz-attempts.js
+++ /dev/null
@@ -1,17 +0,0 @@
-'use strict';
-
-// quiz_attempts.started_at and quiz_attempts.status were added via raw SQL
-// during an earlier development session. The assessment_sessions table now owns
-// session lifecycle; quiz_attempts are always-graded completed records only.
-module.exports = {
- async up(queryInterface, Sequelize) {
- const tableDesc = await queryInterface.describeTable('quiz_attempts');
- if (tableDesc.started_at) await queryInterface.removeColumn('quiz_attempts', 'started_at');
- if (tableDesc.status) await queryInterface.removeColumn('quiz_attempts', 'status');
- },
-
- async down(queryInterface, Sequelize) {
- await queryInterface.addColumn('quiz_attempts', 'started_at', { type: Sequelize.DATE, allowNull: true });
- await queryInterface.addColumn('quiz_attempts', 'status', { type: Sequelize.STRING(20), allowNull: true });
- },
-};
diff --git a/database/migrations/20260101000054-add-draft-to-assessment-sessions.js b/database/migrations/20260101000054-add-draft-to-assessment-sessions.js
deleted file mode 100644
index 52f52df..0000000
--- a/database/migrations/20260101000054-add-draft-to-assessment-sessions.js
+++ /dev/null
@@ -1,20 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- const table = await queryInterface.describeTable('assessment_sessions');
- if (!table.draft_answers)
- await queryInterface.addColumn('assessment_sessions', 'draft_answers', {
- type: Sequelize.JSONB, allowNull: true, defaultValue: null,
- });
- if (!table.last_heartbeat_at)
- await queryInterface.addColumn('assessment_sessions', 'last_heartbeat_at', {
- type: Sequelize.DATE, allowNull: true, defaultValue: null,
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('assessment_sessions', 'last_heartbeat_at');
- await queryInterface.removeColumn('assessment_sessions', 'draft_answers');
- },
-};
diff --git a/database/migrations/20260101000058-add-exclusive-to-course-subscription.js b/database/migrations/20260101000058-add-exclusive-to-course-subscription.js
deleted file mode 100644
index 65b792b..0000000
--- a/database/migrations/20260101000058-add-exclusive-to-course-subscription.js
+++ /dev/null
@@ -1,26 +0,0 @@
-'use strict';
-
-// CockroachDB stores ENUM columns as VARCHAR + check constraint.
-// The original check_subscription constraint only allows 'free' | 'premium'.
-// This migration drops and recreates it to include 'exclusive'.
-module.exports = {
- async up(queryInterface) {
- await queryInterface.sequelize.query(
- `ALTER TABLE courses DROP CONSTRAINT IF EXISTS check_subscription`
- );
- await queryInterface.sequelize.query(
- `ALTER TABLE courses ADD CONSTRAINT check_subscription
- CHECK (subscription IN ('free', 'premium', 'exclusive'))`
- );
- },
-
- async down(queryInterface) {
- await queryInterface.sequelize.query(
- `ALTER TABLE courses DROP CONSTRAINT IF EXISTS check_subscription`
- );
- await queryInterface.sequelize.query(
- `ALTER TABLE courses ADD CONSTRAINT check_subscription
- CHECK (subscription IN ('free', 'premium'))`
- );
- },
-};
diff --git a/database/migrations/20260101000059-add-plan-id-to-user-tiers.js b/database/migrations/20260101000059-add-plan-id-to-user-tiers.js
deleted file mode 100644
index 22843cb..0000000
--- a/database/migrations/20260101000059-add-plan-id-to-user-tiers.js
+++ /dev/null
@@ -1,17 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('user_tiers', 'plan_id', {
- type: Sequelize.BIGINT,
- allowNull: true,
- references: { model: 'tier_plans', key: 'plan_id' },
- onUpdate: 'CASCADE',
- onDelete: 'SET NULL',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('user_tiers', 'plan_id');
- },
-};
diff --git a/database/migrations/20260101000060-create-plan-policies.js b/database/migrations/20260101000060-create-plan-policies.js
deleted file mode 100644
index cb48c2d..0000000
--- a/database/migrations/20260101000060-create-plan-policies.js
+++ /dev/null
@@ -1,25 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('plan_policies', {
- policy_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- plan_id: { type: Sequelize.BIGINT, allowNull: false, unique: true,
- references: { model: 'tier_plans', key: 'plan_id' },
- onUpdate: 'CASCADE', onDelete: 'CASCADE' },
- badge_asset_id: { type: Sequelize.BIGINT, allowNull: true,
- references: { model: 'assets', key: 'asset_id' },
- onUpdate: 'CASCADE', onDelete: 'SET NULL' },
- badge_label: { type: Sequelize.STRING(100), allowNull: false, defaultValue: '' },
- badge_description: { type: Sequelize.TEXT, allowNull: true },
- badge_information: { type: Sequelize.TEXT, allowNull: true },
- access_rules: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('plan_policies');
- },
-};
diff --git a/database/migrations/20260101000063-add-tier-category-to-tier-plans.js b/database/migrations/20260101000063-add-tier-category-to-tier-plans.js
deleted file mode 100644
index 5bd26a1..0000000
--- a/database/migrations/20260101000063-add-tier-category-to-tier-plans.js
+++ /dev/null
@@ -1,39 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- // Add the FK column
- await queryInterface.addColumn('tier_plans', 'tier_category_id', {
- type: Sequelize.BIGINT,
- allowNull: true,
- references: { model: 'tier_categories', key: 'tier_category_id' },
- onUpdate: 'CASCADE',
- onDelete: 'SET NULL',
- });
-
- // Populate from the existing tier slug → tier_categories row
- await queryInterface.sequelize.query(`
- UPDATE tier_plans tp
- SET tier_category_id = tc.tier_category_id
- FROM tier_categories tc
- WHERE tc.slug = tp.tier
- `);
-
- // CockroachDB stores Sequelize ENUMs as VARCHAR + CHECK constraint.
- // Drop the constraint so `tier` becomes a free-form slug that can hold
- // any value matching a tier_categories.slug (including admin-created ones).
- await queryInterface.sequelize.query(
- `ALTER TABLE tier_plans DROP CONSTRAINT IF EXISTS check_tier`
- );
- await queryInterface.sequelize.query(
- `ALTER TABLE tier_plans DROP CONSTRAINT IF EXISTS "tier_plans_tier_check"`
- );
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('tier_plans', 'tier_category_id');
- await queryInterface.sequelize.query(
- `ALTER TABLE tier_plans ADD CONSTRAINT check_tier CHECK (tier IN ('premium', 'exclusive'))`
- );
- },
-};
diff --git a/database/migrations/20260101000064-remove-badge-from-plan-policies.js b/database/migrations/20260101000064-remove-badge-from-plan-policies.js
deleted file mode 100644
index c349c9a..0000000
--- a/database/migrations/20260101000064-remove-badge-from-plan-policies.js
+++ /dev/null
@@ -1,20 +0,0 @@
-'use strict';
-
-// Badge config moves to tier_categories — plan_policies now only holds access_rules.
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.sequelize.query(`SET sql_safe_updates = false`);
- await queryInterface.sequelize.query(`ALTER TABLE plan_policies DROP COLUMN IF EXISTS badge_asset_id`);
- await queryInterface.sequelize.query(`ALTER TABLE plan_policies DROP COLUMN IF EXISTS badge_label`);
- await queryInterface.sequelize.query(`ALTER TABLE plan_policies DROP COLUMN IF EXISTS badge_description`);
- await queryInterface.sequelize.query(`ALTER TABLE plan_policies DROP COLUMN IF EXISTS badge_information`);
- },
-
- async down(queryInterface, Sequelize) {
- await queryInterface.addColumn('plan_policies', 'badge_asset_id', { type: Sequelize.BIGINT, allowNull: true });
- await queryInterface.addColumn('plan_policies', 'badge_label', { type: Sequelize.STRING(100), allowNull: false, defaultValue: '' });
- await queryInterface.addColumn('plan_policies', 'badge_description', { type: Sequelize.TEXT, allowNull: true });
- await queryInterface.addColumn('plan_policies', 'badge_information', { type: Sequelize.TEXT, allowNull: true });
- },
-};
diff --git a/database/migrations/20260101000065-drop-tier-enum-check-user-tiers.js b/database/migrations/20260101000065-drop-tier-enum-check-user-tiers.js
deleted file mode 100644
index 846b484..0000000
--- a/database/migrations/20260101000065-drop-tier-enum-check-user-tiers.js
+++ /dev/null
@@ -1,21 +0,0 @@
-'use strict';
-
-// user_tiers.tier was ENUM('free','premium','exclusive').
-// Drop the check constraint so it can hold any tier_categories.slug value.
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.sequelize.query(
- `ALTER TABLE user_tiers DROP CONSTRAINT IF EXISTS check_tier`
- );
- await queryInterface.sequelize.query(
- `ALTER TABLE user_tiers DROP CONSTRAINT IF EXISTS "user_tiers_tier_check"`
- );
- },
-
- async down(queryInterface) {
- await queryInterface.sequelize.query(
- `ALTER TABLE user_tiers ADD CONSTRAINT check_tier CHECK (tier IN ('free', 'premium', 'exclusive'))`
- );
- },
-};
diff --git a/database/migrations/20260101000066-add-description-to-tier-plans.js b/database/migrations/20260101000066-add-description-to-tier-plans.js
deleted file mode 100644
index 487f74a..0000000
--- a/database/migrations/20260101000066-add-description-to-tier-plans.js
+++ /dev/null
@@ -1,15 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('tier_plans', 'description', {
- type: Sequelize.TEXT,
- allowNull: true,
- after: 'label',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('tier_plans', 'description');
- },
-};
diff --git a/database/migrations/20260101000068-add-duration-unit-to-tier-plans.js b/database/migrations/20260101000068-add-duration-unit-to-tier-plans.js
deleted file mode 100644
index f74a5f7..0000000
--- a/database/migrations/20260101000068-add-duration-unit-to-tier-plans.js
+++ /dev/null
@@ -1,23 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('tier_plans', 'duration_unit', {
- type: Sequelize.STRING(10),
- allowNull: false,
- defaultValue: 'day',
- });
- await queryInterface.changeColumn('tier_plans', 'duration_days', {
- type: Sequelize.FLOAT,
- allowNull: false,
- });
- },
-
- async down(queryInterface, Sequelize) {
- await queryInterface.removeColumn('tier_plans', 'duration_unit');
- await queryInterface.changeColumn('tier_plans', 'duration_days', {
- type: Sequelize.INTEGER,
- allowNull: false,
- });
- },
-};
diff --git a/database/migrations/20260702000001-create-notification-broadcasts.js b/database/migrations/20260702000001-create-notification-broadcasts.js
deleted file mode 100644
index 17ec596..0000000
--- a/database/migrations/20260702000001-create-notification-broadcasts.js
+++ /dev/null
@@ -1,31 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('notification_broadcasts', {
- broadcast_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
- title: { type: Sequelize.STRING(255), allowNull: false },
- message: { type: Sequelize.TEXT, allowNull: false },
- audience: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false },
- status: { type: Sequelize.ENUM('draft', 'sent', 'archived'), allowNull: false, defaultValue: 'draft' },
- sent_at: { type: Sequelize.DATE, allowNull: true },
- recipient_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
- createdBy: { type: Sequelize.BIGINT, allowNull: true },
- updatedBy: { type: Sequelize.BIGINT, allowNull: true },
- deletedBy: { type: Sequelize.BIGINT, allowNull: true },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- deletedAt: { type: Sequelize.DATE, allowNull: true },
- });
-
- await queryInterface.addIndex('notification_broadcasts', ['uuid']);
- await queryInterface.addIndex('notification_broadcasts', ['status']);
- await queryInterface.addIndex('notification_broadcasts', ['audience']);
- await queryInterface.addIndex('notification_broadcasts', ['deletedAt']);
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('notification_broadcasts');
- },
-};
diff --git a/database/migrations/20260702000002-add-target-to-notification-broadcasts.js b/database/migrations/20260702000002-add-target-to-notification-broadcasts.js
deleted file mode 100644
index 1ed20bd..0000000
--- a/database/migrations/20260702000002-add-target-to-notification-broadcasts.js
+++ /dev/null
@@ -1,27 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- // CockroachDB: add enum values in place, then rename the column.
- // Type is named 'notification_broadcast_audience' (matches the explicit CREATE TYPE
- // used when this table was first created — not Sequelize's default enum_
_ name).
- await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'task_list'`);
- await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'course'`);
- await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'tier_plan'`);
-
- await queryInterface.renameColumn('notification_broadcasts', 'audience', 'target_type');
-
- await queryInterface.addColumn('notification_broadcasts', 'target_id', {
- type: Sequelize.STRING(64),
- allowNull: true,
- });
-
- await queryInterface.removeIndex('notification_broadcasts', ['audience']).catch(() => {});
- await queryInterface.addIndex('notification_broadcasts', ['target_type']);
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('notification_broadcasts', 'target_id');
- await queryInterface.renameColumn('notification_broadcasts', 'target_type', 'audience');
- },
-};
diff --git a/database/migrations/20260702000003-create-cron-notification-settings.js b/database/migrations/20260702000003-create-cron-notification-settings.js
deleted file mode 100644
index 9aedecd..0000000
--- a/database/migrations/20260702000003-create-cron-notification-settings.js
+++ /dev/null
@@ -1,18 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('cron_notification_settings', {
- job_name: { type: Sequelize.STRING(64), primaryKey: true },
- enabled: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
- schedule: { type: Sequelize.STRING(20), allowNull: false },
- updatedBy: { type: Sequelize.BIGINT, allowNull: true },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('cron_notification_settings');
- },
-};
diff --git a/database/migrations/20260702000004-add-placement-to-advertisements.js b/database/migrations/20260702000004-add-placement-to-advertisements.js
deleted file mode 100644
index 21b0f9a..0000000
--- a/database/migrations/20260702000004-add-placement-to-advertisements.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('advertisements', 'placement', {
- type: Sequelize.STRING(100),
- allowNull: true,
- });
-
- await queryInterface.addIndex('advertisements', ['placement']);
-
- // Backfill the only two placements that were actually wired up before this
- // migration (Dashboard hero + popup). Any existing banner/sidebar rows are
- // left with placement = NULL — dev data, an admin reassigns them via the
- // edit form once the new Page/Position picker ships.
- await queryInterface.sequelize.query(`
- UPDATE advertisements SET placement = 'dashboard.hero' WHERE type = 'hero' AND placement IS NULL
- `);
- await queryInterface.sequelize.query(`
- UPDATE advertisements SET placement = 'dashboard.popup' WHERE type = 'popup' AND placement IS NULL
- `);
- },
-
- async down(queryInterface) {
- await queryInterface.removeIndex('advertisements', ['placement']).catch(() => {});
- await queryInterface.removeColumn('advertisements', 'placement');
- },
-};
diff --git a/database/migrations/20260703000002-add-icon-to-achievements.js b/database/migrations/20260703000002-add-icon-to-achievements.js
deleted file mode 100644
index 1387ab7..0000000
--- a/database/migrations/20260703000002-add-icon-to-achievements.js
+++ /dev/null
@@ -1,14 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('achievements', 'icon', {
- type: Sequelize.STRING(50),
- allowNull: true,
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('achievements', 'icon');
- },
-};
diff --git a/database/migrations/20260703000008-create-notification-templates.js b/database/migrations/20260703000008-create-notification-templates.js
deleted file mode 100644
index e9608a8..0000000
--- a/database/migrations/20260703000008-create-notification-templates.js
+++ /dev/null
@@ -1,129 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.createTable('notification_templates', {
- notification_template_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- type: { type: Sequelize.STRING(100), allowNull: false, unique: true },
- notify_type: { type: Sequelize.STRING(64), allowNull: false },
- scope: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false },
- label: { type: Sequelize.STRING(150), allowNull: false },
- status: { type: Sequelize.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft' },
- title: { type: Sequelize.STRING(255), allowNull: true },
- message: { type: Sequelize.TEXT, allowNull: true },
- draft_title: { type: Sequelize.STRING(255), allowNull: true },
- draft_message: { type: Sequelize.TEXT, allowNull: true },
- last_sent_at: { type: Sequelize.DATE, allowNull: true },
- is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
-
- const now = new Date();
-
- // Ported straight from data/notifications.data.js's NOTIFICATION_REGISTRY —
- // seeded already-published (status: 'sent', content in the live title/message
- // columns) because these types fire in production right now via cron/event
- // triggers. Seeding them as drafts would make every trigger site throw
- // "no published version yet" the moment this migration lands.
- await queryInterface.bulkInsert('notification_templates', [
- {
- type: 'task_overdue', notify_type: 'task_overdue', scope: 'admin', is_system: true,
- label: 'Tasks Overdue (Admin)', status: 'sent',
- title: 'Tasks Overdue',
- message: '{{count}} {{task_word}} automatically marked as overdue.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'user_registration', notify_type: 'user_registration', scope: 'admin', is_system: true,
- label: 'New User Registered', status: 'sent',
- title: 'New User Registered',
- message: 'A new user registered in {{groupName}}.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'nogrp_user_registered', notify_type: 'nogrp_user_registered', scope: 'admin', is_system: true,
- label: 'New Unaffiliated User', status: 'sent',
- title: 'New Unaffiliated User',
- message: 'A new user ({{userEmail}}) registered via {{regType}} without a group code and was placed in the default group.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'task_requirements_updated', notify_type: 'task', scope: 'user', is_system: true,
- label: 'Task Requirements Updated', status: 'sent',
- title: 'Task Updated',
- message: 'The requirements for "{{taskName}}" have been updated by your administrator.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'user_task_overdue', notify_type: 'task', scope: 'user', is_system: true,
- label: 'Tasks Overdue (User)', status: 'sent',
- title: 'Tasks Overdue',
- message: '{{task_label}} passed their deadline and been marked as overdue.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'task_reminder', notify_type: 'task', scope: 'user', is_system: true,
- label: 'Task Deadline Approaching', status: 'sent',
- title: 'Task Deadline Approaching',
- message: '"{{taskName}}" is due on {{deadline}}.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'course_unlocked', notify_type: 'course', scope: 'user', is_system: true,
- label: 'New Course Available', status: 'sent',
- title: 'New Course Available',
- message: '"{{courseTitle}}" has been added to your learning library.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'course_completed', notify_type: 'course', scope: 'user', is_system: true,
- label: 'Course Completed', status: 'sent',
- title: 'Course Completed',
- message: 'Great job! You\'ve completed "{{courseTitle}}". Your certificate will be issued within the next hour.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'certificate_issued', notify_type: 'course', scope: 'user', is_system: true,
- label: 'Certificate Issued', status: 'sent',
- title: 'Certificate Issued',
- message: 'Congratulations! Your certificate for "{{courseTitle}}" is ready.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'welcome', notify_type: 'announcement', scope: 'user', is_system: true,
- label: 'Welcome Message', status: 'sent',
- title: 'Welcome to Philproperties',
- message: '{{greeting}}{{group_suffix}}',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'nogrp_welcome', notify_type: 'announcement', scope: 'user', is_system: true,
- label: "Not in a Group Yet", status: 'sent',
- title: "You're Not in a Group Yet",
- message: 'You are currently in the default group. Contact an administrator to be assigned to your team.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'assessment_updated', notify_type: 'assessment', scope: 'user', is_system: true,
- label: 'Assessment Updated', status: 'sent',
- title: 'Assessment Updated',
- message: 'The administrator has updated the "{{assessmentTitle}}" in "{{courseTitle}}". Your current session is still valid — continue where you left off.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'tier_expired', notify_type: 'tier_expired', scope: 'user', is_system: true,
- label: 'Subscription Expired', status: 'sent',
- title: 'Subscription Expired',
- message: 'Your {{planLabel}} plan has expired. Renew to keep access.',
- createdAt: now, updatedAt: now,
- },
- ]);
- },
-
- async down(queryInterface) {
- await queryInterface.dropTable('notification_templates');
- await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_scope";`);
- await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_status";`);
- },
-};
diff --git a/database/migrations/20260707000001-junction-revamp-course-units-lessons.js b/database/migrations/20260707000001-junction-revamp-course-units-lessons.js
deleted file mode 100644
index 01928e9..0000000
--- a/database/migrations/20260707000001-junction-revamp-course-units-lessons.js
+++ /dev/null
@@ -1,101 +0,0 @@
-'use strict';
-
-/**
- * Junction revamp — Units and Lessons run independently.
- *
- * courses ⇄ course_units ⇄ units ⇄ unit_lessons ⇄ lessons
- *
- * 1. Create course_units + unit_lessons (ordering lives on the junction rows)
- * 2. Backfill from the old direct FKs (units.course_id / lessons.unit_id),
- * preserving each row's order_index
- * 3. Progress tables accept NULL course/unit context (standalone reads)
- * 4. Drop the old FK + order columns from units / lessons
- */
-module.exports = {
- async up(queryInterface, Sequelize) {
- // ── 1. Junction tables ────────────────────────────────────────────────────
- await queryInterface.createTable('course_units', {
- course_unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
- unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
- order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
- createdBy: { type: Sequelize.BIGINT, allowNull: true },
- updatedBy: { type: Sequelize.BIGINT, allowNull: true },
- createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
- updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
- });
- await queryInterface.addIndex('course_units', { fields: ['course_id', 'unit_id'], unique: true, name: 'uq_course_units_course_unit' });
- await queryInterface.addIndex('course_units', { fields: ['course_id'], name: 'idx_course_units_course_id' });
- await queryInterface.addIndex('course_units', { fields: ['unit_id'], name: 'idx_course_units_unit_id' });
- await queryInterface.addIndex('course_units', { fields: ['order_index'], name: 'idx_course_units_order' });
-
- await queryInterface.createTable('unit_lessons', {
- unit_lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
- lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
- order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
- createdBy: { type: Sequelize.BIGINT, allowNull: true },
- updatedBy: { type: Sequelize.BIGINT, allowNull: true },
- createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
- updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
- });
- await queryInterface.addIndex('unit_lessons', { fields: ['unit_id', 'lesson_id'], unique: true, name: 'uq_unit_lessons_unit_lesson' });
- await queryInterface.addIndex('unit_lessons', { fields: ['unit_id'], name: 'idx_unit_lessons_unit_id' });
- await queryInterface.addIndex('unit_lessons', { fields: ['lesson_id'], name: 'idx_unit_lessons_lesson_id' });
- await queryInterface.addIndex('unit_lessons', { fields: ['order_index'], name: 'idx_unit_lessons_order' });
-
- // ── 2. Backfill from the old direct FKs ───────────────────────────────────
- await queryInterface.sequelize.query(`
- INSERT INTO course_units (course_id, unit_id, order_index, "createdBy", "createdAt", "updatedAt")
- SELECT u.course_id, u.unit_id, COALESCE(u.order_index, 0), u."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
- FROM units u
- WHERE u.course_id IS NOT NULL
- `);
- await queryInterface.sequelize.query(`
- INSERT INTO unit_lessons (unit_id, lesson_id, order_index, "createdBy", "createdAt", "updatedAt")
- SELECT l.unit_id, l.lesson_id, COALESCE(l.order_index, 0), l."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
- FROM lessons l
- WHERE l.unit_id IS NOT NULL
- `);
-
- // ── 3. Progress tables — allow standalone (course-less / unit-less) reads ──
- await queryInterface.changeColumn('unit_reading_progress', 'course_id', { type: Sequelize.BIGINT, allowNull: true });
- await queryInterface.changeColumn('lesson_reading_progress', 'course_id', { type: Sequelize.BIGINT, allowNull: true });
- await queryInterface.changeColumn('lesson_reading_progress', 'unit_id', { type: Sequelize.BIGINT, allowNull: true });
-
- // ── 4. Drop the old FK + order columns ────────────────────────────────────
- await queryInterface.removeColumn('units', 'course_id');
- await queryInterface.removeColumn('units', 'order_index');
- await queryInterface.removeColumn('lessons', 'unit_id');
- await queryInterface.removeColumn('lessons', 'order_index');
- },
-
- async down(queryInterface, Sequelize) {
- // Recreate the direct FK columns and restore one parent per child
- await queryInterface.addColumn('units', 'course_id', { type: Sequelize.BIGINT, allowNull: true });
- await queryInterface.addColumn('units', 'order_index', { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 });
- await queryInterface.addColumn('lessons', 'unit_id', { type: Sequelize.BIGINT, allowNull: true });
- await queryInterface.addColumn('lessons', 'order_index', { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 });
-
- // Keep the FIRST attachment per child (multi-parent data collapses)
- await queryInterface.sequelize.query(`
- UPDATE units u SET course_id = cu.course_id, order_index = cu.order_index
- FROM (SELECT DISTINCT ON (unit_id) unit_id, course_id, order_index
- FROM course_units ORDER BY unit_id, course_unit_id) cu
- WHERE u.unit_id = cu.unit_id
- `);
- await queryInterface.sequelize.query(`
- UPDATE lessons l SET unit_id = ul.unit_id, order_index = ul.order_index
- FROM (SELECT DISTINCT ON (lesson_id) lesson_id, unit_id, order_index
- FROM unit_lessons ORDER BY lesson_id, unit_lesson_id) ul
- WHERE l.lesson_id = ul.lesson_id
- `);
-
- await queryInterface.changeColumn('unit_reading_progress', 'course_id', { type: Sequelize.BIGINT, allowNull: false });
- await queryInterface.changeColumn('lesson_reading_progress', 'course_id', { type: Sequelize.BIGINT, allowNull: false });
- await queryInterface.changeColumn('lesson_reading_progress', 'unit_id', { type: Sequelize.BIGINT, allowNull: false });
-
- await queryInterface.dropTable('unit_lessons');
- await queryInterface.dropTable('course_units');
- },
-};
diff --git a/database/migrations/20260708000001-add-sticky-and-notification-flags.js b/database/migrations/20260708000001-add-sticky-and-notification-flags.js
deleted file mode 100644
index 5367f2b..0000000
--- a/database/migrations/20260708000001-add-sticky-and-notification-flags.js
+++ /dev/null
@@ -1,57 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- // ─── notification_broadcasts ────────────────────────────────────────────
- await queryInterface.addColumn('notification_broadcasts', 'show_in_sticky', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: false,
- });
-
- await queryInterface.addColumn('notification_broadcasts', 'show_in_notifications', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: true,
- });
-
- // ─── admin_notifications (admin bell) ───────────────────────────────────
- await queryInterface.addColumn('admin_notifications', 'show_in_notifications', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: true,
- });
-
- // Not used by admin UI right now, but keeps filtering semantics symmetric.
- await queryInterface.addColumn('admin_notifications', 'show_in_sticky', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: false,
- });
-
- // ─── user_notifications (client notifications + sticky banner) ────────
- await queryInterface.addColumn('user_notifications', 'show_in_notifications', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: true,
- });
-
- await queryInterface.addColumn('user_notifications', 'show_in_sticky', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: false,
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('user_notifications', 'show_in_sticky');
- await queryInterface.removeColumn('user_notifications', 'show_in_notifications');
-
- await queryInterface.removeColumn('admin_notifications', 'show_in_sticky');
- await queryInterface.removeColumn('admin_notifications', 'show_in_notifications');
-
- await queryInterface.removeColumn('notification_broadcasts', 'show_in_notifications');
- await queryInterface.removeColumn('notification_broadcasts', 'show_in_sticky');
- },
-};
-
diff --git a/database/migrations/20260709000001-add-requires-review-and-prompt-to-task-requirements.js b/database/migrations/20260709000001-add-requires-review-and-prompt-to-task-requirements.js
deleted file mode 100644
index f8b1ac4..0000000
--- a/database/migrations/20260709000001-add-requires-review-and-prompt-to-task-requirements.js
+++ /dev/null
@@ -1,30 +0,0 @@
-'use strict';
-
-// Part 1A — new requirement types (submit_text, pass_quiz) + review workflow.
-// `prompt` is submit_text's instructions field (mirrors link_label/reference_label
-// as the type-specific display field). `requires_review` opts any submission-based
-// requirement into admin approve/reject before it counts as complete.
-// task_requirements.type has no DB-level CHECK constraint (verified against the
-// live schema — Sequelize enforces the ENUM only at the application layer for
-// this table), so no constraint migration is needed to add the new type values.
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('task_requirements', 'prompt', {
- type: Sequelize.TEXT,
- allowNull: true,
- after: 'reference_label',
- });
- await queryInterface.addColumn('task_requirements', 'requires_review', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: false,
- after: 'prompt',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('task_requirements', 'requires_review');
- await queryInterface.removeColumn('task_requirements', 'prompt');
- },
-};
diff --git a/database/migrations/20260709000002-add-review-fields-to-task-completions.js b/database/migrations/20260709000002-add-review-fields-to-task-completions.js
deleted file mode 100644
index 3c29a6a..0000000
--- a/database/migrations/20260709000002-add-review-fields-to-task-completions.js
+++ /dev/null
@@ -1,55 +0,0 @@
-'use strict';
-
-// Part 1A — completions gain a review workflow (submitted/approved/rejected)
-// and a response_text field for the new submit_text requirement type.
-// `status` is added as STRING + an explicit CHECK constraint, matching the
-// pattern task_progress.type already uses on this CockroachDB instance
-// (createTable auto-generates `check_type`; addColumn does not, so we add it
-// ourselves) rather than the ENUM-with-no-constraint gap task_requirements.type
-// happens to have today.
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('task_completions', 'response_text', {
- type: Sequelize.TEXT,
- allowNull: true,
- after: 'note',
- });
- await queryInterface.addColumn('task_completions', 'status', {
- type: Sequelize.STRING,
- allowNull: false,
- defaultValue: 'submitted',
- after: 'response_text',
- });
- await queryInterface.sequelize.query(
- `ALTER TABLE task_completions ADD CONSTRAINT check_status
- CHECK (status IN ('submitted', 'approved', 'rejected'))`
- );
- await queryInterface.addColumn('task_completions', 'reviewed_by', {
- type: Sequelize.BIGINT,
- allowNull: true,
- after: 'status',
- });
- await queryInterface.addColumn('task_completions', 'reviewed_at', {
- type: Sequelize.DATE,
- allowNull: true,
- after: 'reviewed_by',
- });
- await queryInterface.addColumn('task_completions', 'review_note', {
- type: Sequelize.TEXT,
- allowNull: true,
- after: 'reviewed_at',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('task_completions', 'review_note');
- await queryInterface.removeColumn('task_completions', 'reviewed_at');
- await queryInterface.removeColumn('task_completions', 'reviewed_by');
- await queryInterface.sequelize.query(
- `ALTER TABLE task_completions DROP CONSTRAINT IF EXISTS check_status`
- );
- await queryInterface.removeColumn('task_completions', 'status');
- await queryInterface.removeColumn('task_completions', 'response_text');
- },
-};
diff --git a/database/migrations/20260709000003-add-pass-quiz-to-task-progress-type.js b/database/migrations/20260709000003-add-pass-quiz-to-task-progress-type.js
deleted file mode 100644
index 0801eb8..0000000
--- a/database/migrations/20260709000003-add-pass-quiz-to-task-progress-type.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict';
-
-// Part 1A — task_progress.type gains 'pass_quiz' (reference_id becomes a
-// quiz_id for that type; completed is computed from QuizAttempt.passed the
-// same way unit-quiz has_passed already is). Same drop/recreate CHECK
-// constraint pattern as 20260101000058-add-exclusive-to-course-subscription.js.
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.sequelize.query(
- `ALTER TABLE task_progress DROP CONSTRAINT IF EXISTS check_type`
- );
- await queryInterface.sequelize.query(
- `ALTER TABLE task_progress ADD CONSTRAINT check_type
- CHECK (type IN ('read_course', 'read_unit', 'read_lesson', 'pass_quiz'))`
- );
- },
-
- async down(queryInterface) {
- await queryInterface.sequelize.query(
- `ALTER TABLE task_progress DROP CONSTRAINT IF EXISTS check_type`
- );
- await queryInterface.sequelize.query(
- `ALTER TABLE task_progress ADD CONSTRAINT check_type
- CHECK (type IN ('read_course', 'read_unit', 'read_lesson'))`
- );
- },
-};
diff --git a/database/migrations/20260709000004-add-task-review-assign-complete-notification-templates.js b/database/migrations/20260709000004-add-task-review-assign-complete-notification-templates.js
deleted file mode 100644
index ea56dc9..0000000
--- a/database/migrations/20260709000004-add-task-review-assign-complete-notification-templates.js
+++ /dev/null
@@ -1,42 +0,0 @@
-'use strict';
-
-// Part 1A/1C — new learner-facing notification types for the task review
-// workflow, group assignment, and per-task completion. Seeded already-published
-// (status: 'sent') like every other is_system row in the original
-// 20260703000008 migration, so the trigger call sites work immediately without
-// requiring an admin to publish them first.
-
-module.exports = {
- async up(queryInterface) {
- const now = new Date();
- await queryInterface.bulkInsert('notification_templates', [
- {
- type: 'task_submission_reviewed', notify_type: 'task', scope: 'user', is_system: true,
- label: 'Task Submission Reviewed', status: 'sent',
- title: 'Submission Reviewed',
- message: 'Your submission for "{{taskName}}" was {{statusLabel}}.{{reviewNoteSuffix}}',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'task_assigned', notify_type: 'task', scope: 'user', is_system: true,
- label: 'Task List Assigned', status: 'sent',
- title: 'New Task Assigned',
- message: 'You have been assigned "{{taskListName}}" — {{taskCount}} task(s) to complete.',
- createdAt: now, updatedAt: now,
- },
- {
- type: 'task_completed', notify_type: 'task', scope: 'user', is_system: true,
- label: 'Task Completed', status: 'sent',
- title: 'Task Completed',
- message: 'You completed "{{taskName}}".',
- createdAt: now, updatedAt: now,
- },
- ]);
- },
-
- async down(queryInterface) {
- await queryInterface.bulkDelete('notification_templates', {
- type: ['task_submission_reviewed', 'task_assigned', 'task_completed'],
- });
- },
-};
diff --git a/database/migrations/20260709000005-add-order-index-and-is-required-to-tasks.js b/database/migrations/20260709000005-add-order-index-and-is-required-to-tasks.js
deleted file mode 100644
index 90b2e11..0000000
--- a/database/migrations/20260709000005-add-order-index-and-is-required-to-tasks.js
+++ /dev/null
@@ -1,39 +0,0 @@
-'use strict';
-
-// Part 1B — task_lists' tasks currently sort only by createdAt (no ordering
-// column exists). Add order_index (position within the list) and is_required
-// (mirrors unit_quizzes.is_required — an optional task doesn't block anything
-// after it in the sequencing lock).
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('tasks', 'order_index', {
- type: Sequelize.INTEGER,
- allowNull: false,
- defaultValue: 0,
- after: 'deadline',
- });
- await queryInterface.addColumn('tasks', 'is_required', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: true,
- after: 'order_index',
- });
-
- // Backfill existing rows with a stable order matching current createdAt
- // sort, so tasks don't all collapse to order_index 0 on first use.
- await queryInterface.sequelize.query(`
- UPDATE tasks t SET order_index = sub.rn - 1
- FROM (
- SELECT task_id, ROW_NUMBER() OVER (PARTITION BY task_list_id ORDER BY "createdAt" ASC) AS rn
- FROM tasks
- ) sub
- WHERE t.task_id = sub.task_id
- `);
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('tasks', 'is_required');
- await queryInterface.removeColumn('tasks', 'order_index');
- },
-};
diff --git a/database/migrations/20260709000006-add-features-to-tier-plans.js b/database/migrations/20260709000006-add-features-to-tier-plans.js
deleted file mode 100644
index 6a5dc64..0000000
--- a/database/migrations/20260709000006-add-features-to-tier-plans.js
+++ /dev/null
@@ -1,19 +0,0 @@
-'use strict';
-
-// Part 2A — admin-authored bullet list ("what's included") per plan, replacing
-// the client's hardcoded 4-item static perks list. Same JSONB-array-of-objects
-// pattern already used for plan_policies.access_rules / payment_policies.promo_rules.
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('tier_plans', 'features', {
- type: Sequelize.JSONB,
- allowNull: true,
- after: 'description',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('tier_plans', 'features');
- },
-};
diff --git a/database/migrations/20260709000007-add-subscription-to-units.js b/database/migrations/20260709000007-add-subscription-to-units.js
deleted file mode 100644
index ee792a7..0000000
--- a/database/migrations/20260709000007-add-subscription-to-units.js
+++ /dev/null
@@ -1,23 +0,0 @@
-'use strict';
-
-// Part 2B — standalone (non-course) units currently can never be tier-gated:
-// canAccessUnit only ever checks course links, and an independent unit by
-// definition has none. Add a direct, optional subscription field so a
-// standalone unit can require a tier on its own. Deliberately no CHECK
-// constraint — mirrors 20260101000065-drop-tier-enum-check-user-tiers.js's
-// fix, since tier slugs are admin-defined (tier_categories.slug) and not a
-// fixed enum.
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('units', 'subscription', {
- type: Sequelize.STRING(50),
- allowNull: true,
- after: 'title',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('units', 'subscription');
- },
-};
diff --git a/database/migrations/20260710000001-add-status-to-courses.js b/database/migrations/20260710000001-add-status-to-courses.js
deleted file mode 100644
index 040bcd1..0000000
--- a/database/migrations/20260710000001-add-status-to-courses.js
+++ /dev/null
@@ -1,34 +0,0 @@
-'use strict';
-
-// Courses gain a publish-state lifecycle (draft/published/unpublished),
-// separate from the existing soft-delete Archive feature. `status` is added
-// as STRING + an explicit CHECK constraint (addColumn doesn't auto-generate
-// one on this CockroachDB instance, unlike createTable), matching the
-// pattern used in 20260709000002-add-review-fields-to-task-completions.js.
-// Existing non-deleted courses are backfilled to 'published' since they're
-// already live/consumed by learners; new courses default to 'draft'.
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('courses', 'status', {
- type: Sequelize.STRING,
- allowNull: false,
- defaultValue: 'draft',
- after: 'subscription',
- });
- await queryInterface.sequelize.query(
- `ALTER TABLE courses ADD CONSTRAINT check_status
- CHECK (status IN ('draft', 'published', 'unpublished'))`
- );
- await queryInterface.sequelize.query(
- `UPDATE courses SET status = 'published' WHERE "deletedAt" IS NULL`
- );
- },
-
- async down(queryInterface) {
- await queryInterface.sequelize.query(
- `ALTER TABLE courses DROP CONSTRAINT IF EXISTS check_status`
- );
- await queryInterface.removeColumn('courses', 'status');
- },
-};
diff --git a/database/migrations/20260711000001-add-link-url-to-notification-broadcasts.js b/database/migrations/20260711000001-add-link-url-to-notification-broadcasts.js
deleted file mode 100644
index 96eb68a..0000000
--- a/database/migrations/20260711000001-add-link-url-to-notification-broadcasts.js
+++ /dev/null
@@ -1,14 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('notification_broadcasts', 'link_url', {
- type: Sequelize.STRING(2048),
- allowNull: true,
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('notification_broadcasts', 'link_url');
- },
-};
diff --git a/database/migrations/20260711000002-recategorize-advertisement-placements.js b/database/migrations/20260711000002-recategorize-advertisement-placements.js
deleted file mode 100644
index 26b1f0b..0000000
--- a/database/migrations/20260711000002-recategorize-advertisement-placements.js
+++ /dev/null
@@ -1,37 +0,0 @@
-'use strict';
-
-// Re-categorizes advertisement placements: Hero stays on Dashboard, Banner
-// consolidates onto Tier Plans + Course Details. Popup (dashboard.popup) and
-// Sidebar (course_details.sidebar) are retired as formats, and the Courses
-// list banner (course_list.banner) is dropped along with them since the new
-// registry only offers Dashboard / Tier Plans / Course Details.
-//
-// Existing rows on a retired placement are archived (soft-deleted) rather
-// than deleted outright — they still show up in Archived Advertisements for
-// an admin to review/permanently delete if desired.
-const RETIRED_PLACEMENTS = ['dashboard.popup', 'course_list.banner', 'course_details.sidebar'];
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.sequelize.query(`
- UPDATE advertisements SET placement = 'tier_plans.banner' WHERE placement = 'plans.banner'
- `);
-
- await queryInterface.sequelize.query(`
- UPDATE advertisements
- SET "deletedAt" = NOW()
- WHERE placement IN (:retired) AND "deletedAt" IS NULL
- `, {
- replacements: { retired: RETIRED_PLACEMENTS },
- });
- },
-
- async down(queryInterface) {
- await queryInterface.sequelize.query(`
- UPDATE advertisements SET placement = 'plans.banner' WHERE placement = 'tier_plans.banner'
- `);
- // Retired-placement rows that were auto-archived by `up` are intentionally
- // left archived — there's no reliable way to distinguish them from ads an
- // admin archived manually in the meantime.
- },
-};
diff --git a/database/migrations/20260711000003-add-content-mode-and-page-builder-to-advertisements.js b/database/migrations/20260711000003-add-content-mode-and-page-builder-to-advertisements.js
deleted file mode 100644
index 11aeb51..0000000
--- a/database/migrations/20260711000003-add-content-mode-and-page-builder-to-advertisements.js
+++ /dev/null
@@ -1,55 +0,0 @@
-'use strict';
-
-// Supports the new advertisement creation wizard:
-// - content_mode: "image" (image only) vs "content" (badge/headline/
-// description/CTAs alongside the image) — an explicit admin choice,
-// decoupled from placement/format. Added as STRING + an explicit CHECK
-// constraint (addColumn can't create a native enum type on this
-// CockroachDB instance, unlike createTable) — matching the pattern used
-// in 20260710000001-add-status-to-courses.js. The Sequelize model still
-// declares it as DataTypes.ENUM for app-level validation; the column
-// itself is a plain string.
-// - redirect_link: where the ad as a whole links to when clicked with no
-// CTA of its own (banners/full-image ads have no CTA row).
-// - landing_page: when no redirect_link is given, an internally-authored
-// landing page (title/description/body/links) the ad's own click-through
-// resolves to instead (see GET /api/client/advertisements/uuid/:uuid and
-// the /ads/:uuid client route).
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('advertisements', 'content_mode', {
- type: Sequelize.STRING,
- allowNull: false,
- defaultValue: 'image',
- });
- await queryInterface.sequelize.query(`
- ALTER TABLE advertisements ADD CONSTRAINT check_content_mode
- CHECK (content_mode IN ('image', 'content'))
- `);
-
- await queryInterface.addColumn('advertisements', 'redirect_link', {
- type: Sequelize.STRING(512),
- allowNull: true,
- });
-
- await queryInterface.addColumn('advertisements', 'landing_page', {
- type: Sequelize.JSONB,
- allowNull: true,
- });
-
- // Existing rows already have real content (headline/description/ctas) —
- // treat them as "content" mode rather than defaulting to "image".
- await queryInterface.sequelize.query(`
- UPDATE advertisements
- SET content_mode = 'content'
- WHERE headline IS NOT NULL AND headline <> ''
- `);
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('advertisements', 'landing_page');
- await queryInterface.removeColumn('advertisements', 'redirect_link');
- await queryInterface.sequelize.query(`ALTER TABLE advertisements DROP CONSTRAINT IF EXISTS check_content_mode`);
- await queryInterface.removeColumn('advertisements', 'content_mode');
- },
-};
diff --git a/database/migrations/20260711000004-drop-notification-templates.js b/database/migrations/20260711000004-drop-notification-templates.js
deleted file mode 100644
index aba5453..0000000
--- a/database/migrations/20260711000004-drop-notification-templates.js
+++ /dev/null
@@ -1,32 +0,0 @@
-'use strict';
-
-// Reverts the notification_templates feature (20260703000008 +
-// 20260709000004) — admin-editable notification wording has been moved back
-// to hardcoded build() functions in data/notifications.data.js.
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.dropTable('notification_templates');
- await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_scope";`);
- await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_status";`);
- },
-
- async down(queryInterface, Sequelize) {
- await queryInterface.createTable('notification_templates', {
- notification_template_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
- type: { type: Sequelize.STRING(100), allowNull: false, unique: true },
- notify_type: { type: Sequelize.STRING(64), allowNull: false },
- scope: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false },
- label: { type: Sequelize.STRING(150), allowNull: false },
- status: { type: Sequelize.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft' },
- title: { type: Sequelize.STRING(255), allowNull: true },
- message: { type: Sequelize.TEXT, allowNull: true },
- draft_title: { type: Sequelize.STRING(255), allowNull: true },
- draft_message: { type: Sequelize.TEXT, allowNull: true },
- last_sent_at: { type: Sequelize.DATE, allowNull: true },
- is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- });
- },
-};
diff --git a/database/migrations/20260711000005-add-color-and-link-label-to-notifications.js b/database/migrations/20260711000005-add-color-and-link-label-to-notifications.js
deleted file mode 100644
index 5251dc6..0000000
--- a/database/migrations/20260711000005-add-color-and-link-label-to-notifications.js
+++ /dev/null
@@ -1,35 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('notification_broadcasts', 'color', {
- type: Sequelize.STRING(20),
- allowNull: false,
- defaultValue: 'indigo',
- });
-
- await queryInterface.addColumn('notification_broadcasts', 'link_label', {
- type: Sequelize.STRING(60),
- allowNull: true,
- });
-
- await queryInterface.addColumn('admin_notifications', 'color', {
- type: Sequelize.STRING(20),
- allowNull: false,
- defaultValue: 'indigo',
- });
-
- await queryInterface.addColumn('user_notifications', 'color', {
- type: Sequelize.STRING(20),
- allowNull: false,
- defaultValue: 'indigo',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('user_notifications', 'color');
- await queryInterface.removeColumn('admin_notifications', 'color');
- await queryInterface.removeColumn('notification_broadcasts', 'link_label');
- await queryInterface.removeColumn('notification_broadcasts', 'color');
- },
-};
diff --git a/database/migrations/20260711000006-add-start-end-date-to-notification-broadcasts.js b/database/migrations/20260711000006-add-start-end-date-to-notification-broadcasts.js
deleted file mode 100644
index fbe22ad..0000000
--- a/database/migrations/20260711000006-add-start-end-date-to-notification-broadcasts.js
+++ /dev/null
@@ -1,44 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('notification_broadcasts', 'start_date', {
- type: Sequelize.DATE,
- allowNull: true,
- });
-
- await queryInterface.addColumn('notification_broadcasts', 'end_date', {
- type: Sequelize.DATE,
- allowNull: true,
- });
-
- await queryInterface.addColumn('admin_notifications', 'start_date', {
- type: Sequelize.DATE,
- allowNull: true,
- });
-
- await queryInterface.addColumn('admin_notifications', 'end_date', {
- type: Sequelize.DATE,
- allowNull: true,
- });
-
- await queryInterface.addColumn('user_notifications', 'start_date', {
- type: Sequelize.DATE,
- allowNull: true,
- });
-
- await queryInterface.addColumn('user_notifications', 'end_date', {
- type: Sequelize.DATE,
- allowNull: true,
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('user_notifications', 'end_date');
- await queryInterface.removeColumn('user_notifications', 'start_date');
- await queryInterface.removeColumn('admin_notifications', 'end_date');
- await queryInterface.removeColumn('admin_notifications', 'start_date');
- await queryInterface.removeColumn('notification_broadcasts', 'end_date');
- await queryInterface.removeColumn('notification_broadcasts', 'start_date');
- },
-};
diff --git a/database/migrations/20260711000007-add-broadcast-id-to-notifications.js b/database/migrations/20260711000007-add-broadcast-id-to-notifications.js
deleted file mode 100644
index 7a45bbc..0000000
--- a/database/migrations/20260711000007-add-broadcast-id-to-notifications.js
+++ /dev/null
@@ -1,24 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('admin_notifications', 'broadcast_id', {
- type: Sequelize.BIGINT,
- allowNull: true,
- references: { model: 'notification_broadcasts', key: 'broadcast_id' },
- onDelete: 'SET NULL',
- });
-
- await queryInterface.addColumn('user_notifications', 'broadcast_id', {
- type: Sequelize.BIGINT,
- allowNull: true,
- references: { model: 'notification_broadcasts', key: 'broadcast_id' },
- onDelete: 'SET NULL',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('user_notifications', 'broadcast_id');
- await queryInterface.removeColumn('admin_notifications', 'broadcast_id');
- },
-};
diff --git a/database/migrations/20260712000001-add-audio-to-assets-file-type-enum.js b/database/migrations/20260712000001-add-audio-to-assets-file-type-enum.js
deleted file mode 100644
index 68aff02..0000000
--- a/database/migrations/20260712000001-add-audio-to-assets-file-type-enum.js
+++ /dev/null
@@ -1,20 +0,0 @@
-'use strict';
-
-// The app has resolved/handled "audio" as a file_type since day one (see
-// resolveFileType() in assets.controller.js, ViewAudioAsset.jsx,
-// mediaToken.SUPPORTED_TYPES) but the enum itself was never extended past
-// the original ('avatar', 'document', 'video', 'image') — every audio
-// upload has been silently 500ing at Asset.create() with an invalid-enum
-// error. Discovered while adding batch asset uploads, which will hit this
-// path directly for any audio file dropped into a batch.
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.sequelize.query(`ALTER TYPE public.enum_assets_file_type ADD VALUE IF NOT EXISTS 'audio'`);
- },
-
- // Postgres has no ALTER TYPE ... DROP VALUE — reverting an enum value
- // addition requires rebuilding the type, which isn't safe to do blindly
- // in a down migration if any row already uses 'audio'.
- async down() {},
-};
diff --git a/database/migrations/20260713000001-restore-requires-review-column.js b/database/migrations/20260713000001-restore-requires-review-column.js
deleted file mode 100644
index 2d8556a..0000000
--- a/database/migrations/20260713000001-restore-requires-review-column.js
+++ /dev/null
@@ -1,23 +0,0 @@
-'use strict';
-
-// task_requirements.requires_review went missing from the live schema even
-// though 20260709000001 is recorded as applied in SequelizeMeta (it was
-// dropped out-of-band, outside the migration system). This restores it
-// idempotently without re-touching `prompt`, which is still present.
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- const table = await queryInterface.describeTable('task_requirements');
- if (!table.requires_review) {
- await queryInterface.addColumn('task_requirements', 'requires_review', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: false,
- });
- }
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('task_requirements', 'requires_review');
- },
-};
diff --git a/database/migrations/20260715000001-add-missing-task-requirement-types.js b/database/migrations/20260715000001-add-missing-task-requirement-types.js
deleted file mode 100644
index abfa124..0000000
--- a/database/migrations/20260715000001-add-missing-task-requirement-types.js
+++ /dev/null
@@ -1,31 +0,0 @@
-'use strict';
-
-// task_requirements.type IS a real DB enum (task_requirement_type), despite
-// 20260709000001-add-requires-review-and-prompt-to-task-requirements.js's comment
-// claiming otherwise ("Sequelize enforces the ENUM only at the application layer
-// for this table, so no constraint migration is needed") — that assumption was
-// wrong. submit_text and pass_quiz were added to the Sequelize model (task.mdl.js)
-// and the admin RequirementBuilder.jsx UI at the same time, but never to the actual
-// DB enum, so creating/querying either type has been failing with
-// "invalid input value for enum task_requirement_type" ever since (found while
-// verifying the new completion_requirements feature's task auto-complete path,
-// which happened to be the first thing to actually query a pass_quiz-typed
-// TaskRequirement against this DB).
-//
-// ALTER TYPE ... ADD VALUE (not CREATE TYPE) is a plain top-level statement, not
-// wrapped in Sequelize's problematic DO $$...$$ block, so this runs fine as a
-// normal migration — no raw-SQL-outside-migrations workaround needed here.
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.sequelize.query(
- `ALTER TYPE task_requirement_type ADD VALUE IF NOT EXISTS 'submit_text'`
- );
- await queryInterface.sequelize.query(
- `ALTER TYPE task_requirement_type ADD VALUE IF NOT EXISTS 'pass_quiz'`
- );
- },
-
- // Postgres/CockroachDB cannot remove a value from an enum type — down is a no-op.
- async down() {},
-};
diff --git a/database/migrations/20260715000002-add-watch-video-listen-audio-types.js b/database/migrations/20260715000002-add-watch-video-listen-audio-types.js
deleted file mode 100644
index 03df3e3..0000000
--- a/database/migrations/20260715000002-add-watch-video-listen-audio-types.js
+++ /dev/null
@@ -1,45 +0,0 @@
-'use strict';
-
-// Adds two new completion-requirement types — watch_video and listen_audio —
-// alongside the existing watch_percent (adjustable-percent) type. Unlike
-// watch_percent, which tracks one aggregate percent across whichever
-// video/audio block the learner happens to be playing, these two require
-// EVERY block of the matching type on the lesson to individually reach 100%,
-// so per-block progress needs its own column (block_progress) rather than
-// reusing the single progress_percent figure.
-//
-// Same CockroachDB constraint as 20260714000001 — DROP+ADD CONSTRAINT instead
-// of an ENUM ALTER, since CockroachDB rejects CREATE TYPE inside the DO $$...$$
-// block Sequelize wraps enum changes in.
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.sequelize.query(`
- ALTER TABLE completion_requirements DROP CONSTRAINT check_cr_type;
- `);
- await queryInterface.sequelize.query(`
- ALTER TABLE completion_requirements
- ADD CONSTRAINT check_cr_type
- CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete', 'watch_video', 'listen_audio'));
- `);
-
- await queryInterface.sequelize.query(`
- ALTER TABLE completion_requirement_progress
- ADD COLUMN block_progress JSONB NULL;
- `);
- },
-
- async down(queryInterface) {
- await queryInterface.sequelize.query(`
- ALTER TABLE completion_requirement_progress DROP COLUMN IF EXISTS block_progress;
- `);
- await queryInterface.sequelize.query(`
- ALTER TABLE completion_requirements DROP CONSTRAINT check_cr_type;
- `);
- await queryInterface.sequelize.query(`
- ALTER TABLE completion_requirements
- ADD CONSTRAINT check_cr_type
- CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete'));
- `);
- },
-};
diff --git a/database/migrations/20260717000002-reshape-block-progress-timestamps.js b/database/migrations/20260717000002-reshape-block-progress-timestamps.js
deleted file mode 100644
index 6436d13..0000000
--- a/database/migrations/20260717000002-reshape-block-progress-timestamps.js
+++ /dev/null
@@ -1,59 +0,0 @@
-'use strict';
-
-// Reshapes completion_requirement_progress.block_progress from
-// { [blockId]: percentNumber } to { [blockId]: { percent, updatedAt } } so
-// recordWatchProgress can validate a reported percent against real elapsed
-// wall-clock time since that block's last sample (anti-skip hardening —
-// closes the "one API call reports 100%" gaming hole). Applies uniformly to
-// watch_percent rows too, which start using block_progress as of this change
-// (previously only watch_video/listen_audio populated it).
-//
-// Data-only migration — the column type itself (JSONB) doesn't change, so
-// this is a plain JS loop + parameterized UPDATE rather than raw jsonb SQL,
-// consistent with this table's CockroachDB-compatibility precedent (see
-// 20260715000002, which avoids native ALTER TYPE/enum SQL for the same reason).
-
-module.exports = {
- async up(queryInterface) {
- const rows = await queryInterface.sequelize.query(
- `SELECT progress_id, block_progress, "updatedAt" FROM completion_requirement_progress WHERE block_progress IS NOT NULL`,
- { type: queryInterface.sequelize.QueryTypes.SELECT }
- );
-
- for (const row of rows) {
- const bp = row.block_progress ?? {};
- const alreadyShaped = Object.values(bp).every((v) => v && typeof v === 'object');
- if (alreadyShaped) continue;
-
- const reshaped = Object.fromEntries(
- Object.entries(bp).map(([blockId, percent]) => [
- blockId, { percent: Number(percent) || 0, updatedAt: row.updatedAt ?? new Date().toISOString() },
- ])
- );
-
- await queryInterface.sequelize.query(
- `UPDATE completion_requirement_progress SET block_progress = :bp WHERE progress_id = :id`,
- { replacements: { bp: JSON.stringify(reshaped), id: row.progress_id } }
- );
- }
- },
-
- async down(queryInterface) {
- const rows = await queryInterface.sequelize.query(
- `SELECT progress_id, block_progress FROM completion_requirement_progress WHERE block_progress IS NOT NULL`,
- { type: queryInterface.sequelize.QueryTypes.SELECT }
- );
-
- for (const row of rows) {
- const bp = row.block_progress ?? {};
- const flattened = Object.fromEntries(
- Object.entries(bp).map(([blockId, v]) => [blockId, v?.percent ?? v])
- );
-
- await queryInterface.sequelize.query(
- `UPDATE completion_requirement_progress SET block_progress = :bp WHERE progress_id = :id`,
- { replacements: { bp: JSON.stringify(flattened), id: row.progress_id } }
- );
- }
- },
-};
diff --git a/database/migrations/20260717000005-add-auto-marked-at-to-tasks.js b/database/migrations/20260717000005-add-auto-marked-at-to-tasks.js
deleted file mode 100644
index e006e4c..0000000
--- a/database/migrations/20260717000005-add-auto-marked-at-to-tasks.js
+++ /dev/null
@@ -1,15 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('tasks', 'auto_marked_at', {
- type: Sequelize.DATE,
- allowNull: true,
- defaultValue: null,
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('tasks', 'auto_marked_at');
- },
-};
diff --git a/database/migrations/20260717000006-add-target-status-to-cron-notification-settings.js b/database/migrations/20260717000006-add-target-status-to-cron-notification-settings.js
deleted file mode 100644
index c8fbcc3..0000000
--- a/database/migrations/20260717000006-add-target-status-to-cron-notification-settings.js
+++ /dev/null
@@ -1,15 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('cron_notification_settings', 'target_status', {
- type: Sequelize.STRING(20),
- allowNull: true,
- defaultValue: null,
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('cron_notification_settings', 'target_status');
- },
-};
diff --git a/database/migrations/20260717000007-add-accepts-submissions-to-tasks.js b/database/migrations/20260717000007-add-accepts-submissions-to-tasks.js
deleted file mode 100644
index 989b7d7..0000000
--- a/database/migrations/20260717000007-add-accepts-submissions-to-tasks.js
+++ /dev/null
@@ -1,16 +0,0 @@
-'use strict';
-
-module.exports = {
- async up(queryInterface, Sequelize) {
- await queryInterface.addColumn('tasks', 'accepts_submissions', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: true,
- after: 'is_required',
- });
- },
-
- async down(queryInterface) {
- await queryInterface.removeColumn('tasks', 'accepts_submissions');
- },
-};
diff --git a/database/migrations/20260720000001-remove-is-required-from-tasks.js b/database/migrations/20260720000001-remove-is-required-from-tasks.js
deleted file mode 100644
index 9fb41fa..0000000
--- a/database/migrations/20260720000001-remove-is-required-from-tasks.js
+++ /dev/null
@@ -1,24 +0,0 @@
-'use strict';
-
-// The order_index/is_required linear sequencing lock (added in
-// 20260709000005-add-order-index-and-is-required-to-tasks) is redundant now
-// that tasks have an explicit prerequisite graph (task_prerequisites, added
-// in 20260717000001-create-task-prerequisites). Locking a task is now purely
-// a function of its task_prerequisites rows — a task with none is never
-// implicitly locked by its position in the list. order_index is kept as a
-// display-order-only column.
-
-module.exports = {
- async up(queryInterface) {
- await queryInterface.removeColumn('tasks', 'is_required');
- },
-
- async down(queryInterface, Sequelize) {
- await queryInterface.addColumn('tasks', 'is_required', {
- type: Sequelize.BOOLEAN,
- allowNull: false,
- defaultValue: true,
- after: 'order_index',
- });
- },
-};
diff --git a/database/migrations/20260101000001-create-users.js b/database/migrations/20270101000001-create-users.js
similarity index 69%
rename from database/migrations/20260101000001-create-users.js
rename to database/migrations/20270101000001-create-users.js
index e03cd63..5edd95b 100644
--- a/database/migrations/20260101000001-create-users.js
+++ b/database/migrations/20270101000001-create-users.js
@@ -10,17 +10,18 @@ module.exports = {
is_verified: { type: Sequelize.BOOLEAN, defaultValue: false },
reg_type: { type: Sequelize.ENUM('google', 'system'), defaultValue: 'system' },
acc_type: { type: Sequelize.ENUM('admin', 'staff', 'user'), defaultValue: 'user' },
+ needs_intro: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
personal_info: { type: Sequelize.JSONB, defaultValue: null },
must_change_password: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
password_expires_at: { type: Sequelize.DATE, allowNull: true },
otp_code: { type: Sequelize.STRING(6), allowNull: true },
otp_expires_at: { type: Sequelize.DATE, allowNull: true },
- createdBy: { type: Sequelize.BIGINT, allowNull: true },
- updatedBy: { type: Sequelize.BIGINT, allowNull: true },
- deletedBy: { type: Sequelize.BIGINT, allowNull: true },
- createdAt: { type: Sequelize.DATE, allowNull: false },
- updatedAt: { type: Sequelize.DATE, allowNull: false },
- deletedAt: { type: Sequelize.DATE, allowNull: true },
+ createdBy: { type: Sequelize.BIGINT, allowNull: true },
+ updatedBy: { type: Sequelize.BIGINT, allowNull: true },
+ deletedBy: { type: Sequelize.BIGINT, allowNull: true },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
diff --git a/database/migrations/20260101000002-create-assets.js b/database/migrations/20270101000002-create-assets.js
similarity index 94%
rename from database/migrations/20260101000002-create-assets.js
rename to database/migrations/20270101000002-create-assets.js
index ca33bfd..42fa361 100644
--- a/database/migrations/20260101000002-create-assets.js
+++ b/database/migrations/20270101000002-create-assets.js
@@ -12,7 +12,7 @@ module.exports = {
mime_type: { type: Sequelize.STRING(100), allowNull: false },
extension: { type: Sequelize.STRING(20) },
checksum: { type: Sequelize.STRING(64) },
- file_type: { type: Sequelize.ENUM('avatar', 'document', 'video', 'image'), allowNull: false, defaultValue: 'image' },
+ file_type: { type: Sequelize.ENUM('avatar', 'document', 'video', 'image', 'audio'), allowNull: false, defaultValue: 'image' },
width: { type: Sequelize.INTEGER },
height: { type: Sequelize.INTEGER },
duration: { type: Sequelize.FLOAT },
@@ -22,6 +22,7 @@ module.exports = {
video_codec: { type: Sequelize.STRING(50) },
audio_codec: { type: Sequelize.STRING(50) },
thumbnail_url: { type: Sequelize.STRING(512) },
+ thumbnail_storage_key: { type: Sequelize.STRING(512), allowNull: true },
description: { type: Sequelize.TEXT },
storage_provider: { type: Sequelize.ENUM('local', 's3', 'gcs', 'cloudinary', 'chibisafe', 'other'), defaultValue: 'local' },
storage_bucket: { type: Sequelize.STRING(255) },
diff --git a/database/migrations/20260101000004-create-user-groups.js b/database/migrations/20270101000003-create-user-groups.js
similarity index 100%
rename from database/migrations/20260101000004-create-user-groups.js
rename to database/migrations/20270101000003-create-user-groups.js
diff --git a/database/migrations/20260101000003-create-user-sessions.js b/database/migrations/20270101000004-create-user-sessions.js
similarity index 100%
rename from database/migrations/20260101000003-create-user-sessions.js
rename to database/migrations/20270101000004-create-user-sessions.js
diff --git a/database/migrations/20260101000005-create-user-group-members.js b/database/migrations/20270101000005-create-user-group-members.js
similarity index 100%
rename from database/migrations/20260101000005-create-user-group-members.js
rename to database/migrations/20270101000005-create-user-group-members.js
diff --git a/database/migrations/20260101000006-create-achievements.js b/database/migrations/20270101000006-create-achievements.js
similarity index 95%
rename from database/migrations/20260101000006-create-achievements.js
rename to database/migrations/20270101000006-create-achievements.js
index e5191a9..77b93c8 100644
--- a/database/migrations/20260101000006-create-achievements.js
+++ b/database/migrations/20270101000006-create-achievements.js
@@ -9,6 +9,7 @@ module.exports = {
key: { type: Sequelize.STRING(100), allowNull: false },
label: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
+ icon: { type: Sequelize.STRING(50), allowNull: true },
granted_by: { type: Sequelize.BIGINT, allowNull: true },
granted_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
metadata: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
diff --git a/database/migrations/20260101000035-create-categories.js b/database/migrations/20270101000007-create-categories.js
similarity index 100%
rename from database/migrations/20260101000035-create-categories.js
rename to database/migrations/20270101000007-create-categories.js
diff --git a/database/migrations/20260101000007-create-courses.js b/database/migrations/20270101000008-create-courses.js
similarity index 65%
rename from database/migrations/20260101000007-create-courses.js
rename to database/migrations/20270101000008-create-courses.js
index 8d89024..9906fd8 100644
--- a/database/migrations/20260101000007-create-courses.js
+++ b/database/migrations/20270101000008-create-courses.js
@@ -1,5 +1,9 @@
'use strict';
+// subscription/status are STRING + explicit CHECK constraints, not native
+// Sequelize.ENUM — matches the live schema after 20260101000058 collapsed
+// `subscription` from ENUM to a CockroachDB-compatible VARCHAR+CHECK column,
+// and 20260710000001 added `status` the same way from the start.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('courses', {
@@ -10,7 +14,8 @@ module.exports = {
course_code: { type: Sequelize.STRING(50), allowNull: true, unique: true },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
level: { type: Sequelize.ENUM('beginner', 'intermediate', 'advanced'), allowNull: true },
- subscription: { type: Sequelize.ENUM('free', 'premium'), allowNull: false, defaultValue: 'free' },
+ subscription: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'free' },
+ status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'draft' },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
@@ -20,6 +25,13 @@ module.exports = {
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
+ await queryInterface.sequelize.query(
+ `ALTER TABLE courses ADD CONSTRAINT check_subscription CHECK (subscription IN ('free', 'premium', 'exclusive'))`
+ );
+ await queryInterface.sequelize.query(
+ `ALTER TABLE courses ADD CONSTRAINT check_status CHECK (status IN ('draft', 'published', 'unpublished'))`
+ );
+
await queryInterface.addIndex('courses', ['uuid']);
await queryInterface.addIndex('courses', ['subscription']);
await queryInterface.addIndex('courses', ['deletedAt']);
diff --git a/database/migrations/20260101000009-create-course-objectives.js b/database/migrations/20270101000009-create-course-objectives.js
similarity index 100%
rename from database/migrations/20260101000009-create-course-objectives.js
rename to database/migrations/20270101000009-create-course-objectives.js
diff --git a/database/migrations/20260101000010-create-course-prerequisites.js b/database/migrations/20270101000010-create-course-prerequisites.js
similarity index 100%
rename from database/migrations/20260101000010-create-course-prerequisites.js
rename to database/migrations/20270101000010-create-course-prerequisites.js
diff --git a/database/migrations/20260101000011-create-course-assessments.js b/database/migrations/20270101000011-create-course-assessments.js
similarity index 100%
rename from database/migrations/20260101000011-create-course-assessments.js
rename to database/migrations/20270101000011-create-course-assessments.js
diff --git a/database/migrations/20260101000012-create-units.js b/database/migrations/20270101000012-create-units.js
similarity index 77%
rename from database/migrations/20260101000012-create-units.js
rename to database/migrations/20270101000012-create-units.js
index d3defc3..1dca115 100644
--- a/database/migrations/20260101000012-create-units.js
+++ b/database/migrations/20270101000012-create-units.js
@@ -1,14 +1,16 @@
'use strict';
+// units no longer holds a direct course_id / order_index — the junction
+// revamp (originally 20260707000001) moved course<->unit attachment (and its
+// ordering) onto course_units, so units can now be standalone.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('units', {
unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
- course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
title: { type: Sequelize.STRING(255), allowNull: false },
+ subscription: { type: Sequelize.STRING(50), allowNull: true },
description: { type: Sequelize.TEXT, allowNull: true },
- order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
@@ -19,8 +21,6 @@ module.exports = {
});
await queryInterface.addIndex('units', ['uuid']);
- await queryInterface.addIndex('units', ['course_id']);
- await queryInterface.addIndex('units', ['order_index']);
await queryInterface.addIndex('units', ['deletedAt']);
},
diff --git a/database/migrations/20260101000013-create-unit-quizzes.js b/database/migrations/20270101000013-create-unit-quizzes.js
similarity index 100%
rename from database/migrations/20260101000013-create-unit-quizzes.js
rename to database/migrations/20270101000013-create-unit-quizzes.js
diff --git a/database/migrations/20260101000014-create-lessons.js b/database/migrations/20270101000014-create-lessons.js
similarity index 77%
rename from database/migrations/20260101000014-create-lessons.js
rename to database/migrations/20270101000014-create-lessons.js
index cbba2ff..b4c3cca 100644
--- a/database/migrations/20260101000014-create-lessons.js
+++ b/database/migrations/20270101000014-create-lessons.js
@@ -1,15 +1,15 @@
'use strict';
+// lessons no longer holds a direct unit_id / order_index — moved onto
+// unit_lessons by the junction revamp (originally 20260707000001).
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lessons', {
lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
- unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
title: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
- order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
@@ -19,8 +19,6 @@ module.exports = {
});
await queryInterface.addIndex('lessons', ['uuid']);
- await queryInterface.addIndex('lessons', ['unit_id']);
- await queryInterface.addIndex('lessons', ['order_index']);
await queryInterface.addIndex('lessons', ['deletedAt']);
},
diff --git a/database/migrations/20260101000015-create-lesson-pages.js b/database/migrations/20270101000015-create-lesson-pages.js
similarity index 100%
rename from database/migrations/20260101000015-create-lesson-pages.js
rename to database/migrations/20270101000015-create-lesson-pages.js
diff --git a/database/migrations/20260101000016-create-lesson-objectives.js b/database/migrations/20270101000016-create-lesson-objectives.js
similarity index 100%
rename from database/migrations/20260101000016-create-lesson-objectives.js
rename to database/migrations/20270101000016-create-lesson-objectives.js
diff --git a/database/migrations/20260101000017-create-quiz-questions.js b/database/migrations/20270101000017-create-quiz-questions.js
similarity index 100%
rename from database/migrations/20260101000017-create-quiz-questions.js
rename to database/migrations/20270101000017-create-quiz-questions.js
diff --git a/database/migrations/20260101000018-create-quiz-options.js b/database/migrations/20270101000018-create-quiz-options.js
similarity index 100%
rename from database/migrations/20260101000018-create-quiz-options.js
rename to database/migrations/20270101000018-create-quiz-options.js
diff --git a/database/migrations/20260101000036-create-products.js b/database/migrations/20270101000019-create-products.js
similarity index 100%
rename from database/migrations/20260101000036-create-products.js
rename to database/migrations/20270101000019-create-products.js
diff --git a/database/migrations/20260101000037-create-course-purchases.js b/database/migrations/20270101000020-create-course-purchases.js
similarity index 100%
rename from database/migrations/20260101000037-create-course-purchases.js
rename to database/migrations/20270101000020-create-course-purchases.js
diff --git a/database/migrations/20270101000021-create-course-product-categories.js b/database/migrations/20270101000021-create-course-product-categories.js
new file mode 100644
index 0000000..fd8d132
--- /dev/null
+++ b/database/migrations/20270101000021-create-course-product-categories.js
@@ -0,0 +1,17 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('course_product_categories', {
+ id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
+ category_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'categories', key: 'id' }, onDelete: 'CASCADE' },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('course_product_categories');
+ },
+};
diff --git a/database/migrations/20260717000004-create-course-roles.js b/database/migrations/20270101000022-create-course-roles.js
similarity index 100%
rename from database/migrations/20260717000004-create-course-roles.js
rename to database/migrations/20270101000022-create-course-roles.js
diff --git a/database/migrations/20260101000062-create-tier-categories.js b/database/migrations/20270101000023-create-tier-categories.js
similarity index 100%
rename from database/migrations/20260101000062-create-tier-categories.js
rename to database/migrations/20270101000023-create-tier-categories.js
diff --git a/database/migrations/20270101000024-create-tier-plans.js b/database/migrations/20270101000024-create-tier-plans.js
new file mode 100644
index 0000000..65b4ff2
--- /dev/null
+++ b/database/migrations/20270101000024-create-tier-plans.js
@@ -0,0 +1,30 @@
+'use strict';
+
+// `tier` started as ENUM('premium','exclusive') but 20260101000063 dropped
+// its CHECK constraint entirely so it can hold any tier_categories.slug
+// value (including admin-created ones) — it's a free-form STRING now, not
+// an enum.
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('tier_plans', {
+ plan_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ tier: { type: Sequelize.STRING(20), allowNull: false },
+ label: { type: Sequelize.STRING(100), allowNull: false },
+ description: { type: Sequelize.TEXT, allowNull: true },
+ features: { type: Sequelize.JSONB, allowNull: true },
+ duration_days: { type: Sequelize.FLOAT, allowNull: false },
+ duration_unit: { type: Sequelize.STRING(10), allowNull: false, defaultValue: 'day' },
+ price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
+ currency: { type: Sequelize.CHAR(3), allowNull: false, defaultValue: 'USD' },
+ is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
+ tier_category_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'tier_categories', key: 'tier_category_id' }, onUpdate: 'CASCADE', onDelete: 'SET NULL' },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ deletedAt: { type: Sequelize.DATE, allowNull: true },
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('tier_plans');
+ },
+};
diff --git a/database/migrations/20260101000021-create-plan-courses.js b/database/migrations/20270101000025-create-plan-courses.js
similarity index 100%
rename from database/migrations/20260101000021-create-plan-courses.js
rename to database/migrations/20270101000025-create-plan-courses.js
diff --git a/database/migrations/20260101000022-create-user-tiers.js b/database/migrations/20270101000026-create-user-tiers.js
similarity index 73%
rename from database/migrations/20260101000022-create-user-tiers.js
rename to database/migrations/20270101000026-create-user-tiers.js
index 26dbda6..792cfda 100644
--- a/database/migrations/20260101000022-create-user-tiers.js
+++ b/database/migrations/20270101000026-create-user-tiers.js
@@ -1,11 +1,14 @@
'use strict';
+// `tier` started as ENUM('free','premium','exclusive') but 20260101000065
+// dropped its CHECK constraint entirely — free-form STRING now, matching
+// any tier_categories.slug value.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_tiers', {
tier_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
- tier: { type: Sequelize.ENUM('free', 'premium', 'exclusive'), allowNull: false, defaultValue: 'free' },
+ tier: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'free' },
status: { type: Sequelize.ENUM('active', 'expired', 'revoked'), allowNull: false, defaultValue: 'active' },
starts_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
expires_at: { type: Sequelize.DATE, allowNull: true },
@@ -13,6 +16,7 @@ module.exports = {
revoked_by: { type: Sequelize.BIGINT, allowNull: true },
revoked_at: { type: Sequelize.DATE, allowNull: true },
notes: { type: Sequelize.TEXT, allowNull: true },
+ plan_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'tier_plans', key: 'plan_id' }, onUpdate: 'CASCADE', onDelete: 'SET NULL' },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
diff --git a/database/migrations/20260101000023-create-payments.js b/database/migrations/20270101000027-create-payments.js
similarity index 100%
rename from database/migrations/20260101000023-create-payments.js
rename to database/migrations/20270101000027-create-payments.js
diff --git a/database/migrations/20270101000028-create-plan-policies.js b/database/migrations/20270101000028-create-plan-policies.js
new file mode 100644
index 0000000..ff296b8
--- /dev/null
+++ b/database/migrations/20270101000028-create-plan-policies.js
@@ -0,0 +1,21 @@
+'use strict';
+
+// badge_* columns (badge_asset_id/label/description/information) were
+// dropped by 20260101000064 — badge config now lives on tier_categories.
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('plan_policies', {
+ policy_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ plan_id: { type: Sequelize.BIGINT, allowNull: false, unique: true,
+ references: { model: 'tier_plans', key: 'plan_id' },
+ onUpdate: 'CASCADE', onDelete: 'CASCADE' },
+ access_rules: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('plan_policies');
+ },
+};
diff --git a/database/migrations/20260101000067-create-payment-policies.js b/database/migrations/20270101000029-create-payment-policies.js
similarity index 100%
rename from database/migrations/20260101000067-create-payment-policies.js
rename to database/migrations/20270101000029-create-payment-policies.js
diff --git a/database/migrations/20260101000061-create-system-badges.js b/database/migrations/20270101000030-create-system-badges.js
similarity index 100%
rename from database/migrations/20260101000061-create-system-badges.js
rename to database/migrations/20270101000030-create-system-badges.js
diff --git a/database/migrations/20270101000031-create-quiz-attempts.js b/database/migrations/20270101000031-create-quiz-attempts.js
new file mode 100644
index 0000000..7305238
--- /dev/null
+++ b/database/migrations/20270101000031-create-quiz-attempts.js
@@ -0,0 +1,47 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('quiz_attempts', {
+ attempt_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
+ user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
+ quiz_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'unit_quizzes', key: 'quiz_id' }, onDelete: 'SET NULL' },
+ assessment_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'SET NULL' },
+ answers: { type: Sequelize.JSONB, allowNull: false, defaultValue: {} },
+ total_points: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
+ earned_points: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
+ score: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
+ passed: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
+ attempt_number: {
+ type: Sequelize.INTEGER,
+ allowNull: false,
+ defaultValue: 1,
+ comment: '1-based counter per user + quiz (or user + assessment). Derived at insert time from prior attempt count.',
+ },
+ passing_score: {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ comment: 'Snapshot of the required passing score at the time of submission.',
+ },
+ course_id: {
+ type: Sequelize.BIGINT,
+ allowNull: true,
+ references: { model: 'courses', key: 'course_id' },
+ onDelete: 'SET NULL',
+ comment: 'Denormalized course reference for fast per-course attempt queries.',
+ },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ });
+
+ await queryInterface.addIndex('quiz_attempts', ['user_id']);
+ await queryInterface.addIndex('quiz_attempts', ['quiz_id']);
+ await queryInterface.addIndex('quiz_attempts', ['assessment_id']);
+ await queryInterface.addIndex('quiz_attempts', { fields: ['course_id'], name: 'idx_qa_course_id' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('quiz_attempts');
+ },
+};
diff --git a/database/migrations/20260101000024-create-advertisements.js b/database/migrations/20270101000032-create-advertisements.js
similarity index 76%
rename from database/migrations/20260101000024-create-advertisements.js
rename to database/migrations/20270101000032-create-advertisements.js
index 2351656..c1cd51e 100644
--- a/database/migrations/20260101000024-create-advertisements.js
+++ b/database/migrations/20270101000032-create-advertisements.js
@@ -1,5 +1,8 @@
'use strict';
+// content_mode is STRING + explicit CHECK (CockroachDB can't create a new
+// enum type via addColumn the way createTable can) — matches the live schema
+// since 20260711000003.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('advertisements', {
@@ -19,6 +22,10 @@ module.exports = {
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
size: { type: Sequelize.ENUM('sm', 'md', 'lg'), allowNull: true },
click_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
+ placement: { type: Sequelize.STRING(100), allowNull: true },
+ content_mode: { type: Sequelize.STRING, allowNull: false, defaultValue: 'image' },
+ redirect_link: { type: Sequelize.STRING(512), allowNull: true },
+ landing_page: { type: Sequelize.JSONB, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
@@ -27,11 +34,17 @@ module.exports = {
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
+ await queryInterface.sequelize.query(`
+ ALTER TABLE advertisements ADD CONSTRAINT check_content_mode
+ CHECK (content_mode IN ('image', 'content'))
+ `);
+
await queryInterface.addIndex('advertisements', ['uuid']);
await queryInterface.addIndex('advertisements', ['type']);
await queryInterface.addIndex('advertisements', ['status']);
await queryInterface.addIndex('advertisements', ['is_active']);
await queryInterface.addIndex('advertisements', ['deletedAt']);
+ await queryInterface.addIndex('advertisements', ['placement']);
},
async down(queryInterface) {
diff --git a/database/migrations/20260101000025-create-task-lists.js b/database/migrations/20270101000033-create-task-lists.js
similarity index 100%
rename from database/migrations/20260101000025-create-task-lists.js
rename to database/migrations/20270101000033-create-task-lists.js
diff --git a/database/migrations/20260101000026-create-task-list-groups.js b/database/migrations/20270101000034-create-task-list-groups.js
similarity index 100%
rename from database/migrations/20260101000026-create-task-list-groups.js
rename to database/migrations/20270101000034-create-task-list-groups.js
diff --git a/database/migrations/20270101000035-create-tasks.js b/database/migrations/20270101000035-create-tasks.js
new file mode 100644
index 0000000..5f4f77d
--- /dev/null
+++ b/database/migrations/20270101000035-create-tasks.js
@@ -0,0 +1,33 @@
+'use strict';
+
+// is_required (added 20260709000005) was later dropped by 20260720000001 —
+// locking now comes purely from task_prerequisites, not linear position, so
+// it's excluded here. order_index is display-order only.
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('tasks', {
+ task_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
+ task_list_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, onDelete: 'CASCADE' },
+ name: { type: Sequelize.STRING, allowNull: false },
+ description: { type: Sequelize.TEXT, allowNull: true },
+ deadline: { type: Sequelize.DATE, allowNull: true },
+ order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
+ accepts_submissions: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
+ status: { type: Sequelize.ENUM('pending', 'in_progress', 'completed', 'overdue'), defaultValue: 'pending', allowNull: false },
+ auto_marked_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
+ createdBy: { type: Sequelize.INTEGER, allowNull: true },
+ updatedBy: { type: Sequelize.INTEGER, allowNull: true },
+ deletedBy: { type: Sequelize.INTEGER, allowNull: true },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ deletedAt: { type: Sequelize.DATE, allowNull: true },
+ });
+
+ await queryInterface.addIndex('tasks', ['task_list_id']);
+ await queryInterface.addIndex('tasks', ['status']);
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('tasks');
+ },
+};
diff --git a/database/migrations/20260101000028-create-task-requirements.js b/database/migrations/20270101000036-create-task-requirements.js
similarity index 71%
rename from database/migrations/20260101000028-create-task-requirements.js
rename to database/migrations/20270101000036-create-task-requirements.js
index 7c8bdd4..1df1732 100644
--- a/database/migrations/20260101000028-create-task-requirements.js
+++ b/database/migrations/20270101000036-create-task-requirements.js
@@ -1,18 +1,24 @@
'use strict';
+// The live enum type backing `type` is named task_requirement_type (not the
+// Sequelize-default enum_task_requirements_type) — submit_text/pass_quiz were
+// added to it later via ALTER TYPE (20260715000001). Declared here with the
+// full merged value set.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_requirements', {
requirement_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
- type: { type: Sequelize.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson'), allowNull: false },
+ type: { type: Sequelize.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson', 'submit_text', 'pass_quiz'), allowNull: false },
link_url: { type: Sequelize.STRING, allowNull: true },
link_label: { type: Sequelize.STRING, allowNull: true },
allowed_file_types: { type: Sequelize.JSONB, allowNull: true },
max_file_count: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 1 },
reference_id: { type: Sequelize.UUID, allowNull: true },
reference_label: { type: Sequelize.STRING, allowNull: true },
- order: { type: Sequelize.INTEGER, defaultValue: 0 },
+ prompt: { type: Sequelize.TEXT, allowNull: true },
+ requires_review: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
+ order: { type: Sequelize.INTEGER, defaultValue: 0 },
createdBy: { type: Sequelize.INTEGER, allowNull: true },
updatedBy: { type: Sequelize.INTEGER, allowNull: true },
deletedBy: { type: Sequelize.INTEGER, allowNull: true },
diff --git a/database/migrations/20260101000029-create-task-completions.js b/database/migrations/20270101000037-create-task-completions.js
similarity index 73%
rename from database/migrations/20260101000029-create-task-completions.js
rename to database/migrations/20270101000037-create-task-completions.js
index e59df9f..61e478f 100644
--- a/database/migrations/20260101000029-create-task-completions.js
+++ b/database/migrations/20270101000037-create-task-completions.js
@@ -7,6 +7,11 @@ module.exports = {
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
note: { type: Sequelize.TEXT, allowNull: true },
+ response_text: { type: Sequelize.TEXT, allowNull: true },
+ status: { type: Sequelize.STRING, allowNull: false, defaultValue: 'submitted' },
+ reviewed_by: { type: Sequelize.BIGINT, allowNull: true },
+ reviewed_at: { type: Sequelize.DATE, allowNull: true },
+ review_note: { type: Sequelize.TEXT, allowNull: true },
submitted_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW, allowNull: false },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
@@ -16,6 +21,11 @@ module.exports = {
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
+ await queryInterface.sequelize.query(
+ `ALTER TABLE task_completions ADD CONSTRAINT check_status
+ CHECK (status IN ('submitted', 'approved', 'rejected'))`
+ );
+
await queryInterface.addIndex('task_completions', { fields: ['task_id'], name: 'idx_tc_task_id' });
await queryInterface.addIndex('task_completions', { fields: ['user_id'], name: 'idx_tc_user_id' });
await queryInterface.addIndex('task_completions', { fields: ['task_id', 'user_id'], name: 'idx_tc_task_user' });
diff --git a/database/migrations/20260101000030-create-task-completion-files.js b/database/migrations/20270101000038-create-task-completion-files.js
similarity index 100%
rename from database/migrations/20260101000030-create-task-completion-files.js
rename to database/migrations/20270101000038-create-task-completion-files.js
diff --git a/database/migrations/20260101000031-create-task-link-visits.js b/database/migrations/20270101000039-create-task-link-visits.js
similarity index 100%
rename from database/migrations/20260101000031-create-task-link-visits.js
rename to database/migrations/20270101000039-create-task-link-visits.js
diff --git a/database/migrations/20260101000032-create-task-progress.js b/database/migrations/20270101000040-create-task-progress.js
similarity index 79%
rename from database/migrations/20260101000032-create-task-progress.js
rename to database/migrations/20270101000040-create-task-progress.js
index 2af6f62..fea48b1 100644
--- a/database/migrations/20260101000032-create-task-progress.js
+++ b/database/migrations/20270101000040-create-task-progress.js
@@ -1,5 +1,8 @@
'use strict';
+// `type` is STRING + an explicit CHECK constraint (check_type) — pass_quiz
+// was added to the value set via a DROP/ADD CONSTRAINT swap (20260709000003),
+// not ALTER TYPE, confirming this column isn't a native enum on this DB.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_progress', {
@@ -8,7 +11,7 @@ module.exports = {
requirement_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_requirements', key: 'requirement_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
reference_id: { type: Sequelize.UUID, allowNull: false },
- type: { type: Sequelize.ENUM('read_course', 'read_unit', 'read_lesson'), allowNull: false },
+ type: { type: Sequelize.STRING, allowNull: false },
completed: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
completed_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
@@ -17,6 +20,11 @@ module.exports = {
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
+ await queryInterface.sequelize.query(
+ `ALTER TABLE task_progress ADD CONSTRAINT check_type
+ CHECK (type IN ('read_course', 'read_unit', 'read_lesson', 'pass_quiz'))`
+ );
+
await queryInterface.addIndex('task_progress', { fields: ['requirement_id', 'user_id', 'reference_id'], unique: true, name: 'uq_tp_requirement_user_reference' });
await queryInterface.addIndex('task_progress', { fields: ['task_id'], name: 'idx_tp_task_id' });
await queryInterface.addIndex('task_progress', { fields: ['user_id'], name: 'idx_tp_user_id' });
diff --git a/database/migrations/20260717000001-create-task-prerequisites.js b/database/migrations/20270101000041-create-task-prerequisites.js
similarity index 65%
rename from database/migrations/20260717000001-create-task-prerequisites.js
rename to database/migrations/20270101000041-create-task-prerequisites.js
index 748ef37..22c8d04 100644
--- a/database/migrations/20260717000001-create-task-prerequisites.js
+++ b/database/migrations/20270101000041-create-task-prerequisites.js
@@ -1,5 +1,11 @@
'use strict';
+// Index names use a `tpq_` (task-prerequisite) prefix instead of the
+// original migration's `tp_` prefix — the original names (idx_tp_task_id,
+// idx_tp_prerequisite_task_id) collide with task_progress's own
+// idx_tp_task_id (see 20270101000040). CockroachDB apparently tolerates
+// duplicate index names across tables, but Postgres does not, so this is
+// fixed here rather than replicated — flagged for Kenneth's awareness.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_prerequisites', {
@@ -11,8 +17,8 @@ module.exports = {
});
await queryInterface.addIndex('task_prerequisites', { fields: ['task_id', 'prerequisite_task_id'], unique: true, name: 'uq_task_prerequisite' });
- await queryInterface.addIndex('task_prerequisites', { fields: ['task_id'], name: 'idx_tp_task_id' });
- await queryInterface.addIndex('task_prerequisites', { fields: ['prerequisite_task_id'], name: 'idx_tp_prerequisite_task_id' });
+ await queryInterface.addIndex('task_prerequisites', { fields: ['task_id'], name: 'idx_tpq_task_id' });
+ await queryInterface.addIndex('task_prerequisites', { fields: ['prerequisite_task_id'], name: 'idx_tpq_prerequisite_task_id' });
},
async down(queryInterface) {
diff --git a/database/migrations/20270101000042-create-notification-broadcasts.js b/database/migrations/20270101000042-create-notification-broadcasts.js
new file mode 100644
index 0000000..40408b2
--- /dev/null
+++ b/database/migrations/20270101000042-create-notification-broadcasts.js
@@ -0,0 +1,42 @@
+'use strict';
+
+// `target_type` was originally `audience` (ENUM admin/user/both), renamed and
+// extended with task_list/course/tier_plan via ALTER TYPE ADD VALUE on the
+// live type `notification_broadcast_audience` (20260702000002).
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('notification_broadcasts', {
+ broadcast_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
+ title: { type: Sequelize.STRING(255), allowNull: false },
+ message: { type: Sequelize.TEXT, allowNull: false },
+ target_type: { type: Sequelize.ENUM('admin', 'user', 'both', 'task_list', 'course', 'tier_plan'), allowNull: false },
+ target_id: { type: Sequelize.STRING(64), allowNull: true },
+ status: { type: Sequelize.ENUM('draft', 'sent', 'archived'), allowNull: false, defaultValue: 'draft' },
+ sent_at: { type: Sequelize.DATE, allowNull: true },
+ recipient_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
+ link_url: { type: Sequelize.STRING(2048), allowNull: true },
+ link_label: { type: Sequelize.STRING(60), allowNull: true },
+ color: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'indigo' },
+ show_in_sticky: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
+ show_in_notifications: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
+ start_date: { type: Sequelize.DATE, allowNull: true },
+ end_date: { type: Sequelize.DATE, allowNull: true },
+ createdBy: { type: Sequelize.BIGINT, allowNull: true },
+ updatedBy: { type: Sequelize.BIGINT, allowNull: true },
+ deletedBy: { type: Sequelize.BIGINT, allowNull: true },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ deletedAt: { type: Sequelize.DATE, allowNull: true },
+ });
+
+ await queryInterface.addIndex('notification_broadcasts', ['uuid']);
+ await queryInterface.addIndex('notification_broadcasts', ['status']);
+ await queryInterface.addIndex('notification_broadcasts', ['target_type']);
+ await queryInterface.addIndex('notification_broadcasts', ['deletedAt']);
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('notification_broadcasts');
+ },
+};
diff --git a/database/migrations/20270101000043-create-admin-notifications.js b/database/migrations/20270101000043-create-admin-notifications.js
new file mode 100644
index 0000000..d911aa5
--- /dev/null
+++ b/database/migrations/20270101000043-create-admin-notifications.js
@@ -0,0 +1,31 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('admin_notifications', {
+ notification_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ type: { type: Sequelize.STRING(64), allowNull: false },
+ title: { type: Sequelize.STRING(255), allowNull: false },
+ message: { type: Sequelize.TEXT, allowNull: false },
+ data: { type: Sequelize.JSONB, allowNull: true, defaultValue: null },
+ seen: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
+ seen_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
+ show_in_notifications: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
+ show_in_sticky: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
+ color: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'indigo' },
+ start_date: { type: Sequelize.DATE, allowNull: true },
+ end_date: { type: Sequelize.DATE, allowNull: true },
+ broadcast_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'notification_broadcasts', key: 'broadcast_id' }, onDelete: 'SET NULL' },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ });
+
+ await queryInterface.addIndex('admin_notifications', { fields: ['seen'], name: 'idx_an_seen' });
+ await queryInterface.addIndex('admin_notifications', { fields: ['type'], name: 'idx_an_type' });
+ await queryInterface.addIndex('admin_notifications', { fields: ['createdAt'], name: 'idx_an_created_at' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('admin_notifications');
+ },
+};
diff --git a/database/migrations/20270101000044-create-user-notifications.js b/database/migrations/20270101000044-create-user-notifications.js
new file mode 100644
index 0000000..c254c24
--- /dev/null
+++ b/database/migrations/20270101000044-create-user-notifications.js
@@ -0,0 +1,33 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('user_notifications', {
+ notification_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
+ type: { type: Sequelize.STRING(64), allowNull: false },
+ title: { type: Sequelize.STRING(255), allowNull: false },
+ message: { type: Sequelize.TEXT, allowNull: false },
+ data: { type: Sequelize.JSONB, allowNull: true, defaultValue: null },
+ seen: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
+ seen_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
+ show_in_notifications: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
+ show_in_sticky: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
+ color: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'indigo' },
+ start_date: { type: Sequelize.DATE, allowNull: true },
+ end_date: { type: Sequelize.DATE, allowNull: true },
+ broadcast_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'notification_broadcasts', key: 'broadcast_id' }, onDelete: 'SET NULL' },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ });
+
+ await queryInterface.addIndex('user_notifications', { fields: ['user_id'], name: 'idx_un_user_id' });
+ await queryInterface.addIndex('user_notifications', { fields: ['user_id', 'seen'], name: 'idx_un_seen' });
+ await queryInterface.addIndex('user_notifications', { fields: ['type'], name: 'idx_un_type' });
+ await queryInterface.addIndex('user_notifications', { fields: ['createdAt'], name: 'idx_un_created_at' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('user_notifications');
+ },
+};
diff --git a/database/migrations/20260101000040-create-course-reading-progress.js b/database/migrations/20270101000045-create-course-reading-progress.js
similarity index 100%
rename from database/migrations/20260101000040-create-course-reading-progress.js
rename to database/migrations/20270101000045-create-course-reading-progress.js
diff --git a/database/migrations/20270101000046-create-course-units.js b/database/migrations/20270101000046-create-course-units.js
new file mode 100644
index 0000000..e7943fd
--- /dev/null
+++ b/database/migrations/20270101000046-create-course-units.js
@@ -0,0 +1,25 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('course_units', {
+ course_unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
+ unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
+ order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
+ createdBy: { type: Sequelize.BIGINT, allowNull: true },
+ updatedBy: { type: Sequelize.BIGINT, allowNull: true },
+ createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
+ updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
+ });
+
+ await queryInterface.addIndex('course_units', { fields: ['course_id', 'unit_id'], unique: true, name: 'uq_course_units_course_unit' });
+ await queryInterface.addIndex('course_units', { fields: ['course_id'], name: 'idx_course_units_course_id' });
+ await queryInterface.addIndex('course_units', { fields: ['unit_id'], name: 'idx_course_units_unit_id' });
+ await queryInterface.addIndex('course_units', { fields: ['order_index'], name: 'idx_course_units_order' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('course_units');
+ },
+};
diff --git a/database/migrations/20270101000047-create-unit-lessons.js b/database/migrations/20270101000047-create-unit-lessons.js
new file mode 100644
index 0000000..3a0d4f5
--- /dev/null
+++ b/database/migrations/20270101000047-create-unit-lessons.js
@@ -0,0 +1,25 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('unit_lessons', {
+ unit_lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
+ lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
+ order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
+ createdBy: { type: Sequelize.BIGINT, allowNull: true },
+ updatedBy: { type: Sequelize.BIGINT, allowNull: true },
+ createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
+ updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
+ });
+
+ await queryInterface.addIndex('unit_lessons', { fields: ['unit_id', 'lesson_id'], unique: true, name: 'uq_unit_lessons_unit_lesson' });
+ await queryInterface.addIndex('unit_lessons', { fields: ['unit_id'], name: 'idx_unit_lessons_unit_id' });
+ await queryInterface.addIndex('unit_lessons', { fields: ['lesson_id'], name: 'idx_unit_lessons_lesson_id' });
+ await queryInterface.addIndex('unit_lessons', { fields: ['order_index'], name: 'idx_unit_lessons_order' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('unit_lessons');
+ },
+};
diff --git a/database/migrations/20260101000056-create-unit-reading-progress.js b/database/migrations/20270101000048-create-unit-reading-progress.js
similarity index 88%
rename from database/migrations/20260101000056-create-unit-reading-progress.js
rename to database/migrations/20270101000048-create-unit-reading-progress.js
index 1556925..62da824 100644
--- a/database/migrations/20260101000056-create-unit-reading-progress.js
+++ b/database/migrations/20270101000048-create-unit-reading-progress.js
@@ -1,11 +1,13 @@
'use strict';
+// course_id is nullable — the junction revamp (20260707000001) allows
+// standalone (course-less) unit reads.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('unit_reading_progress', {
progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
- course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
+ course_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
status: { type: Sequelize.ENUM('in_progress', 'completed'), allowNull: false, defaultValue: 'in_progress' },
completed_at: { type: Sequelize.DATE, allowNull: true },
diff --git a/database/migrations/20260101000057-create-lesson-reading-progress.js b/database/migrations/20270101000049-create-lesson-reading-progress.js
similarity index 83%
rename from database/migrations/20260101000057-create-lesson-reading-progress.js
rename to database/migrations/20270101000049-create-lesson-reading-progress.js
index f04f6d8..8584ae3 100644
--- a/database/migrations/20260101000057-create-lesson-reading-progress.js
+++ b/database/migrations/20270101000049-create-lesson-reading-progress.js
@@ -1,12 +1,14 @@
'use strict';
+// course_id and unit_id are nullable — the junction revamp (20260707000001)
+// allows standalone (course-less / unit-less) lesson reads.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lesson_reading_progress', {
progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
- course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
- unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
+ course_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
+ unit_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
status: { type: Sequelize.ENUM('in_progress', 'completed'), allowNull: false, defaultValue: 'in_progress' },
completed_at: { type: Sequelize.DATE, allowNull: true },
diff --git a/database/migrations/20260101000041b-create-pages.js b/database/migrations/20270101000050-create-pages.js
similarity index 100%
rename from database/migrations/20260101000041b-create-pages.js
rename to database/migrations/20270101000050-create-pages.js
diff --git a/database/migrations/20260101000042-create-landing-pages.js b/database/migrations/20270101000051-create-landing-pages.js
similarity index 100%
rename from database/migrations/20260101000042-create-landing-pages.js
rename to database/migrations/20270101000051-create-landing-pages.js
diff --git a/database/migrations/20260101000043-create-page-sections.js b/database/migrations/20270101000052-create-page-sections.js
similarity index 100%
rename from database/migrations/20260101000043-create-page-sections.js
rename to database/migrations/20270101000052-create-page-sections.js
diff --git a/database/migrations/20260101000044-create-page-blocks.js b/database/migrations/20270101000053-create-page-blocks.js
similarity index 100%
rename from database/migrations/20260101000044-create-page-blocks.js
rename to database/migrations/20270101000053-create-page-blocks.js
diff --git a/database/migrations/20260101000045-create-page-templates.js b/database/migrations/20270101000054-create-page-templates.js
similarity index 100%
rename from database/migrations/20260101000045-create-page-templates.js
rename to database/migrations/20270101000054-create-page-templates.js
diff --git a/database/migrations/20260101000046-create-announcements.js b/database/migrations/20270101000055-create-announcements.js
similarity index 100%
rename from database/migrations/20260101000046-create-announcements.js
rename to database/migrations/20270101000055-create-announcements.js
diff --git a/database/migrations/20270101000056-create-announcement-courses.js b/database/migrations/20270101000056-create-announcement-courses.js
new file mode 100644
index 0000000..3df42bf
--- /dev/null
+++ b/database/migrations/20270101000056-create-announcement-courses.js
@@ -0,0 +1,38 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('announcement_courses', {
+ id: {
+ type: Sequelize.BIGINT,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+ announcement_id: {
+ type: Sequelize.BIGINT,
+ allowNull: false,
+ references: { model: 'announcements', key: 'id' },
+ onDelete: 'CASCADE',
+ },
+ course_id: {
+ type: Sequelize.BIGINT,
+ allowNull: false,
+ references: { model: 'courses', key: 'course_id' },
+ onDelete: 'CASCADE',
+ },
+ });
+
+ await queryInterface.addConstraint('announcement_courses', {
+ fields: ['announcement_id', 'course_id'],
+ type: 'unique',
+ name: 'uq_announcement_courses',
+ });
+
+ await queryInterface.addIndex('announcement_courses', { fields: ['announcement_id'], name: 'idx_announcement_courses_ann_id' });
+ await queryInterface.addIndex('announcement_courses', { fields: ['course_id'], name: 'idx_announcement_courses_course_id' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('announcement_courses');
+ },
+};
diff --git a/database/migrations/20270101000057-create-announcement-roles.js b/database/migrations/20270101000057-create-announcement-roles.js
new file mode 100644
index 0000000..dfe6b11
--- /dev/null
+++ b/database/migrations/20270101000057-create-announcement-roles.js
@@ -0,0 +1,35 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('announcement_roles', {
+ id: {
+ type: Sequelize.BIGINT,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+ announcement_id: {
+ type: Sequelize.BIGINT,
+ allowNull: false,
+ references: { model: 'announcements', key: 'id' },
+ onDelete: 'CASCADE',
+ },
+ role: {
+ type: Sequelize.STRING(50),
+ allowNull: false,
+ },
+ });
+
+ await queryInterface.addConstraint('announcement_roles', {
+ fields: ['announcement_id', 'role'],
+ type: 'unique',
+ name: 'uq_announcement_roles',
+ });
+
+ await queryInterface.addIndex('announcement_roles', { fields: ['announcement_id'], name: 'idx_announcement_roles_ann_id' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('announcement_roles');
+ },
+};
diff --git a/database/migrations/20270101000058-create-announcement-reads.js b/database/migrations/20270101000058-create-announcement-reads.js
new file mode 100644
index 0000000..9110009
--- /dev/null
+++ b/database/migrations/20270101000058-create-announcement-reads.js
@@ -0,0 +1,43 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('announcement_reads', {
+ id: {
+ type: Sequelize.BIGINT,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+ announcement_id: {
+ type: Sequelize.BIGINT,
+ allowNull: false,
+ references: { model: 'announcements', key: 'id' },
+ onDelete: 'CASCADE',
+ },
+ user_id: {
+ type: Sequelize.BIGINT,
+ allowNull: false,
+ references: { model: 'users', key: 'user_id' },
+ onDelete: 'CASCADE',
+ },
+ read_at: {
+ type: Sequelize.DATE,
+ allowNull: false,
+ defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
+ },
+ });
+
+ await queryInterface.addConstraint('announcement_reads', {
+ fields: ['announcement_id', 'user_id'],
+ type: 'unique',
+ name: 'uq_announcement_reads',
+ });
+
+ await queryInterface.addIndex('announcement_reads', { fields: ['announcement_id'], name: 'idx_announcement_reads_ann_id' });
+ await queryInterface.addIndex('announcement_reads', { fields: ['user_id'], name: 'idx_announcement_reads_user_id' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('announcement_reads');
+ },
+};
diff --git a/database/migrations/20260101000048-create-user-activity.js b/database/migrations/20270101000059-create-user-activity.js
similarity index 85%
rename from database/migrations/20260101000048-create-user-activity.js
rename to database/migrations/20270101000059-create-user-activity.js
index 2221c69..c6da9a4 100644
--- a/database/migrations/20260101000048-create-user-activity.js
+++ b/database/migrations/20270101000059-create-user-activity.js
@@ -1,9 +1,5 @@
'use strict';
-// NOTE: This table was created manually before migration support was added.
-// This migration reflects the final column set including entity_type and entity_id
-// which were added via ALTER TABLE on 2026-06-21.
-
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_activity', {
diff --git a/database/migrations/20260101000049-create-page-user-groups.js b/database/migrations/20270101000060-create-page-user-groups.js
similarity index 100%
rename from database/migrations/20260101000049-create-page-user-groups.js
rename to database/migrations/20270101000060-create-page-user-groups.js
diff --git a/database/migrations/20270101000061-create-assessment-sessions.js b/database/migrations/20270101000061-create-assessment-sessions.js
new file mode 100644
index 0000000..12af91c
--- /dev/null
+++ b/database/migrations/20270101000061-create-assessment-sessions.js
@@ -0,0 +1,29 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('assessment_sessions', {
+ session_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
+ uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true, allowNull: false },
+ user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
+ assessment_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'CASCADE' },
+ course_id: { type: Sequelize.BIGINT, allowNull: true },
+ started_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
+ expires_at: { type: Sequelize.DATE, allowNull: true },
+ status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'in_progress' },
+ attempt_id: { type: Sequelize.BIGINT, allowNull: true },
+ draft_answers: { type: Sequelize.JSONB, allowNull: true, defaultValue: null },
+ last_heartbeat_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ });
+
+ await queryInterface.addIndex('assessment_sessions', ['user_id'], { name: 'idx_as_user_id' });
+ await queryInterface.addIndex('assessment_sessions', ['assessment_id'], { name: 'idx_as_assessment_id' });
+ await queryInterface.addIndex('assessment_sessions', ['status'], { name: 'idx_as_status' });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('assessment_sessions');
+ },
+};
diff --git a/database/migrations/20260101000055-create-pending-certificates.js b/database/migrations/20270101000062-create-pending-certificates.js
similarity index 100%
rename from database/migrations/20260101000055-create-pending-certificates.js
rename to database/migrations/20270101000062-create-pending-certificates.js
diff --git a/database/migrations/20270101000063-create-cron-notification-settings.js b/database/migrations/20270101000063-create-cron-notification-settings.js
new file mode 100644
index 0000000..5f99601
--- /dev/null
+++ b/database/migrations/20270101000063-create-cron-notification-settings.js
@@ -0,0 +1,19 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('cron_notification_settings', {
+ job_name: { type: Sequelize.STRING(64), primaryKey: true },
+ enabled: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
+ schedule: { type: Sequelize.STRING(20), allowNull: false },
+ target_status: { type: Sequelize.STRING(20), allowNull: true, defaultValue: null },
+ updatedBy: { type: Sequelize.BIGINT, allowNull: true },
+ createdAt: { type: Sequelize.DATE, allowNull: false },
+ updatedAt: { type: Sequelize.DATE, allowNull: false },
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('cron_notification_settings');
+ },
+};
diff --git a/database/migrations/20260703000001-create-achievement-definitions.js b/database/migrations/20270101000064-create-achievement-definitions.js
similarity index 100%
rename from database/migrations/20260703000001-create-achievement-definitions.js
rename to database/migrations/20270101000064-create-achievement-definitions.js
diff --git a/database/migrations/20260705000001-create-trusted-devices.js b/database/migrations/20270101000065-create-trusted-devices.js
similarity index 100%
rename from database/migrations/20260705000001-create-trusted-devices.js
rename to database/migrations/20270101000065-create-trusted-devices.js
diff --git a/database/migrations/20260711000008-create-sticky-banner-settings.js b/database/migrations/20270101000066-create-sticky-banner-settings.js
similarity index 89%
rename from database/migrations/20260711000008-create-sticky-banner-settings.js
rename to database/migrations/20270101000066-create-sticky-banner-settings.js
index 8d80e0b..cdfed77 100644
--- a/database/migrations/20260711000008-create-sticky-banner-settings.js
+++ b/database/migrations/20270101000066-create-sticky-banner-settings.js
@@ -5,8 +5,7 @@ module.exports = {
// Single shared banner image for the rotating sticky announcement bar —
// one image for all (up to 3) concurrently-active announcements, not one
// per announcement. Enforced as a singleton in application code (always
- // read/written as id=1), same pragmatic approach as the "no need for a
- // dedicated Banner entity" ask — just a one-row setting.
+ // read/written as id=1) — just a one-row setting.
await queryInterface.createTable('sticky_banner_settings', {
id: {
type: Sequelize.SMALLINT,
diff --git a/database/migrations/20260714000001-create-completion-requirements.js b/database/migrations/20270101000067-create-completion-requirements.js
similarity index 73%
rename from database/migrations/20260714000001-create-completion-requirements.js
rename to database/migrations/20270101000067-create-completion-requirements.js
index 725a7ce..25488b4 100644
--- a/database/migrations/20260714000001-create-completion-requirements.js
+++ b/database/migrations/20270101000067-create-completion-requirements.js
@@ -1,13 +1,9 @@
'use strict';
-// Sequelize's createTable() wraps ENUM creation in a `DO $$...$$` block for
-// idempotency, which CockroachDB rejects ("CREATE TYPE usage inside a function
-// definition is not supported"). Built as raw SQL with STRING + CHECK
-// constraints instead, matching the pattern established in
-// 20260710000001-add-status-to-courses.js. Sequelize's DataTypes.ENUM at the
-// model layer works fine against a STRING+CHECK column — no model-side change
-// needed.
-
+// Raw SQL — Sequelize's createTable() wraps ENUM creation in a `DO $$...$$`
+// block for idempotency, which CockroachDB rejects ("CREATE TYPE usage
+// inside a function definition is not supported"). STRING + CHECK instead,
+// same pattern used throughout this schema for CRDB compatibility.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
@@ -27,7 +23,7 @@ module.exports = {
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMPTZ NULL,
CONSTRAINT check_cr_entity_type CHECK (entity_type IN ('course', 'unit', 'lesson')),
- CONSTRAINT check_cr_type CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete'))
+ CONSTRAINT check_cr_type CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete', 'watch_video', 'listen_audio'))
);
`);
diff --git a/database/migrations/20260714000002-create-completion-requirement-progress.js b/database/migrations/20270101000068-create-completion-requirement-progress.js
similarity index 94%
rename from database/migrations/20260714000002-create-completion-requirement-progress.js
rename to database/migrations/20270101000068-create-completion-requirement-progress.js
index f612387..ca87c99 100644
--- a/database/migrations/20260714000002-create-completion-requirement-progress.js
+++ b/database/migrations/20270101000068-create-completion-requirement-progress.js
@@ -1,8 +1,7 @@
'use strict';
// Raw SQL for the same CockroachDB DO-block/CREATE TYPE reason as
-// 20260714000001-create-completion-requirements.js.
-
+// 20270101000067-create-completion-requirements.js.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
@@ -15,6 +14,7 @@ module.exports = {
progress_percent INTEGER NULL,
completed BOOLEAN NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ NULL,
+ block_progress JSONB NULL,
"createdBy" BIGINT NULL,
"updatedBy" BIGINT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
diff --git a/database/migrations/20260715000003-create-certificates.js b/database/migrations/20270101000069-create-certificates.js
similarity index 83%
rename from database/migrations/20260715000003-create-certificates.js
rename to database/migrations/20270101000069-create-certificates.js
index 83c22bc..23cd87c 100644
--- a/database/migrations/20260715000003-create-certificates.js
+++ b/database/migrations/20270101000069-create-certificates.js
@@ -1,11 +1,5 @@
'use strict';
-// Backfill migration: this table already exists live (created outside of
-// sequelize-cli before migration coverage was complete). Written to match
-// the live CockroachDB schema exactly so a fresh install/cutover recreates
-// it. On the existing production DB, mark this filename as already applied
-// in SequelizeMeta instead of running it — see project notes.
-
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('certificates', {
diff --git a/database/migrations/20260715000004-create-course-instructors.js b/database/migrations/20270101000070-create-course-instructors.js
similarity index 75%
rename from database/migrations/20260715000004-create-course-instructors.js
rename to database/migrations/20270101000070-create-course-instructors.js
index 14b0f8a..ea8c069 100644
--- a/database/migrations/20260715000004-create-course-instructors.js
+++ b/database/migrations/20270101000070-create-course-instructors.js
@@ -1,11 +1,5 @@
'use strict';
-// Backfill migration: this table already exists live (created outside of
-// sequelize-cli before migration coverage was complete). Written to match
-// the live CockroachDB schema exactly so a fresh install/cutover recreates
-// it. On the existing production DB, mark this filename as already applied
-// in SequelizeMeta instead of running it — see project notes.
-
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_instructors', {
@@ -28,8 +22,8 @@ module.exports = {
await queryInterface.addIndex('course_instructors', ['course_id'], { name: 'idx_course_instructors_course' });
// Partial unique index — same columns as the constraint above, scoped to
- // non-null user_id, matching what's live. Raw SQL since queryInterface
- // addIndex's `where` support is unreliable for IS NOT NULL across dialects.
+ // non-null user_id. Raw SQL since queryInterface addIndex's `where`
+ // support is unreliable for IS NOT NULL across dialects.
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX idx_course_instructors_user
ON course_instructors (course_id, user_id)
diff --git a/database/migrations/20260715000005-create-quiz-sessions.js b/database/migrations/20270101000071-create-quiz-sessions.js
similarity index 73%
rename from database/migrations/20260715000005-create-quiz-sessions.js
rename to database/migrations/20270101000071-create-quiz-sessions.js
index a2894ec..02bfa43 100644
--- a/database/migrations/20260715000005-create-quiz-sessions.js
+++ b/database/migrations/20270101000071-create-quiz-sessions.js
@@ -1,15 +1,8 @@
'use strict';
-// Backfill migration: this table already exists live (created outside of
-// sequelize-cli before migration coverage was complete). Written to match
-// the live CockroachDB schema exactly so a fresh install/cutover recreates
-// it. On the existing production DB, mark this filename as already applied
-// in SequelizeMeta instead of running it — see project notes.
-//
-// Note: started_at/last_saved_at/createdAt/updatedAt are TIMESTAMP WITHOUT
-// TIME ZONE live (unlike most other tables' TIMESTAMPTZ columns), so raw
-// type strings are used instead of Sequelize.DATE to match exactly.
-
+// started_at/last_saved_at/createdAt/updatedAt are TIMESTAMP WITHOUT TIME
+// ZONE live (unlike most other tables' TIMESTAMPTZ columns), so raw type
+// strings are used instead of Sequelize.DATE to match exactly.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_sessions', {
diff --git a/database/migrations/20260715000006-create-user-bans.js b/database/migrations/20270101000072-create-user-bans.js
similarity index 84%
rename from database/migrations/20260715000006-create-user-bans.js
rename to database/migrations/20270101000072-create-user-bans.js
index a41bb38..15a4616 100644
--- a/database/migrations/20260715000006-create-user-bans.js
+++ b/database/migrations/20270101000072-create-user-bans.js
@@ -1,11 +1,5 @@
'use strict';
-// Backfill migration: this table already exists live (created outside of
-// sequelize-cli before migration coverage was complete). Written to match
-// the live CockroachDB schema exactly so a fresh install/cutover recreates
-// it. On the existing production DB, mark this filename as already applied
-// in SequelizeMeta instead of running it — see project notes.
-
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_bans', {
diff --git a/database/migrations/20260717000003-create-media-playback-positions.js b/database/migrations/20270101000073-create-media-playback-positions.js
similarity index 82%
rename from database/migrations/20260717000003-create-media-playback-positions.js
rename to database/migrations/20270101000073-create-media-playback-positions.js
index 42b777d..a030454 100644
--- a/database/migrations/20260717000003-create-media-playback-positions.js
+++ b/database/migrations/20270101000073-create-media-playback-positions.js
@@ -1,12 +1,11 @@
'use strict';
// Raw SQL for the same CockroachDB DO-block/CREATE TYPE reason as
-// 20260714000002-create-completion-requirement-progress.js.
+// 20270101000067-create-completion-requirements.js.
//
-// Decoupled from completion_requirements entirely — tracks last-known playback
-// position for ANY video/audio block, regardless of whether the lesson has a
-// watch-type completion requirement configured. Powers resume-on-reopen only.
-
+// Decoupled from completion_requirements entirely — tracks last-known
+// playback position for ANY video/audio block, regardless of whether the
+// lesson has a watch-type completion requirement configured.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
diff --git a/database/migrations/20260101000071-create-course-achievements.js b/database/migrations/20270101000074-create-course-achievements.js
similarity index 100%
rename from database/migrations/20260101000071-create-course-achievements.js
rename to database/migrations/20270101000074-create-course-achievements.js