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