mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
updated db migration
This commit is contained in:
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-35
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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 });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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'))`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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'))`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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 });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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'))`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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_<table>_<col> 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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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";`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
-30
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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'))`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-42
@@ -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'],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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.
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-55
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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() {},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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() {},
|
|
||||||
};
|
|
||||||
@@ -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'));
|
|
||||||
`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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 } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+7
-6
@@ -10,17 +10,18 @@ module.exports = {
|
|||||||
is_verified: { type: Sequelize.BOOLEAN, defaultValue: false },
|
is_verified: { type: Sequelize.BOOLEAN, defaultValue: false },
|
||||||
reg_type: { type: Sequelize.ENUM('google', 'system'), defaultValue: 'system' },
|
reg_type: { type: Sequelize.ENUM('google', 'system'), defaultValue: 'system' },
|
||||||
acc_type: { type: Sequelize.ENUM('admin', 'staff', 'user'), defaultValue: 'user' },
|
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 },
|
personal_info: { type: Sequelize.JSONB, defaultValue: null },
|
||||||
must_change_password: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
|
must_change_password: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
|
||||||
password_expires_at: { type: Sequelize.DATE, allowNull: true },
|
password_expires_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
otp_code: { type: Sequelize.STRING(6), allowNull: true },
|
otp_code: { type: Sequelize.STRING(6), allowNull: true },
|
||||||
otp_expires_at: { type: Sequelize.DATE, allowNull: true },
|
otp_expires_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
|
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
createdAt: { type: Sequelize.DATE, allowNull: false },
|
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
deletedAt: { type: Sequelize.DATE, allowNull: true },
|
deletedAt: { type: Sequelize.DATE, allowNull: true },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
+2
-1
@@ -12,7 +12,7 @@ module.exports = {
|
|||||||
mime_type: { type: Sequelize.STRING(100), allowNull: false },
|
mime_type: { type: Sequelize.STRING(100), allowNull: false },
|
||||||
extension: { type: Sequelize.STRING(20) },
|
extension: { type: Sequelize.STRING(20) },
|
||||||
checksum: { type: Sequelize.STRING(64) },
|
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 },
|
width: { type: Sequelize.INTEGER },
|
||||||
height: { type: Sequelize.INTEGER },
|
height: { type: Sequelize.INTEGER },
|
||||||
duration: { type: Sequelize.FLOAT },
|
duration: { type: Sequelize.FLOAT },
|
||||||
@@ -22,6 +22,7 @@ module.exports = {
|
|||||||
video_codec: { type: Sequelize.STRING(50) },
|
video_codec: { type: Sequelize.STRING(50) },
|
||||||
audio_codec: { type: Sequelize.STRING(50) },
|
audio_codec: { type: Sequelize.STRING(50) },
|
||||||
thumbnail_url: { type: Sequelize.STRING(512) },
|
thumbnail_url: { type: Sequelize.STRING(512) },
|
||||||
|
thumbnail_storage_key: { type: Sequelize.STRING(512), allowNull: true },
|
||||||
description: { type: Sequelize.TEXT },
|
description: { type: Sequelize.TEXT },
|
||||||
storage_provider: { type: Sequelize.ENUM('local', 's3', 'gcs', 'cloudinary', 'chibisafe', 'other'), defaultValue: 'local' },
|
storage_provider: { type: Sequelize.ENUM('local', 's3', 'gcs', 'cloudinary', 'chibisafe', 'other'), defaultValue: 'local' },
|
||||||
storage_bucket: { type: Sequelize.STRING(255) },
|
storage_bucket: { type: Sequelize.STRING(255) },
|
||||||
+1
@@ -9,6 +9,7 @@ module.exports = {
|
|||||||
key: { type: Sequelize.STRING(100), allowNull: false },
|
key: { type: Sequelize.STRING(100), allowNull: false },
|
||||||
label: { type: Sequelize.STRING(255), allowNull: false },
|
label: { type: Sequelize.STRING(255), allowNull: false },
|
||||||
description: { type: Sequelize.TEXT, allowNull: true },
|
description: { type: Sequelize.TEXT, allowNull: true },
|
||||||
|
icon: { type: Sequelize.STRING(50), allowNull: true },
|
||||||
granted_by: { type: Sequelize.BIGINT, allowNull: true },
|
granted_by: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
granted_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
|
granted_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
|
||||||
metadata: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
|
metadata: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
|
||||||
+13
-1
@@ -1,5 +1,9 @@
|
|||||||
'use strict';
|
'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 = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable('courses', {
|
await queryInterface.createTable('courses', {
|
||||||
@@ -10,7 +14,8 @@ module.exports = {
|
|||||||
course_code: { type: Sequelize.STRING(50), allowNull: true, unique: true },
|
course_code: { type: Sequelize.STRING(50), allowNull: true, unique: true },
|
||||||
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
level: { type: Sequelize.ENUM('beginner', 'intermediate', 'advanced'), allowNull: true },
|
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 },
|
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
|
||||||
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
@@ -20,6 +25,13 @@ module.exports = {
|
|||||||
deletedAt: { type: Sequelize.DATE, allowNull: true },
|
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', ['uuid']);
|
||||||
await queryInterface.addIndex('courses', ['subscription']);
|
await queryInterface.addIndex('courses', ['subscription']);
|
||||||
await queryInterface.addIndex('courses', ['deletedAt']);
|
await queryInterface.addIndex('courses', ['deletedAt']);
|
||||||
+4
-4
@@ -1,14 +1,16 @@
|
|||||||
'use strict';
|
'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 = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable('units', {
|
await queryInterface.createTable('units', {
|
||||||
unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: 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 },
|
title: { type: Sequelize.STRING(255), allowNull: false },
|
||||||
|
subscription: { type: Sequelize.STRING(50), allowNull: true },
|
||||||
description: { type: Sequelize.TEXT, 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 },
|
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
|
||||||
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
updatedBy: { 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', ['uuid']);
|
||||||
await queryInterface.addIndex('units', ['course_id']);
|
|
||||||
await queryInterface.addIndex('units', ['order_index']);
|
|
||||||
await queryInterface.addIndex('units', ['deletedAt']);
|
await queryInterface.addIndex('units', ['deletedAt']);
|
||||||
},
|
},
|
||||||
|
|
||||||
+2
-4
@@ -1,15 +1,15 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// lessons no longer holds a direct unit_id / order_index — moved onto
|
||||||
|
// unit_lessons by the junction revamp (originally 20260707000001).
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable('lessons', {
|
await queryInterface.createTable('lessons', {
|
||||||
lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: 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 },
|
title: { type: Sequelize.STRING(255), allowNull: false },
|
||||||
description: { type: Sequelize.TEXT, allowNull: true },
|
description: { type: Sequelize.TEXT, allowNull: true },
|
||||||
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
|
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
|
||||||
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
|
||||||
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
deletedBy: { 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', ['uuid']);
|
||||||
await queryInterface.addIndex('lessons', ['unit_id']);
|
|
||||||
await queryInterface.addIndex('lessons', ['order_index']);
|
|
||||||
await queryInterface.addIndex('lessons', ['deletedAt']);
|
await queryInterface.addIndex('lessons', ['deletedAt']);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
+5
-1
@@ -1,11 +1,14 @@
|
|||||||
'use strict';
|
'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 = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable('user_tiers', {
|
await queryInterface.createTable('user_tiers', {
|
||||||
tier_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
tier_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
|
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' },
|
status: { type: Sequelize.ENUM('active', 'expired', 'revoked'), allowNull: false, defaultValue: 'active' },
|
||||||
starts_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
|
starts_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
|
||||||
expires_at: { type: Sequelize.DATE, allowNull: true },
|
expires_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
@@ -13,6 +16,7 @@ module.exports = {
|
|||||||
revoked_by: { type: Sequelize.BIGINT, allowNull: true },
|
revoked_by: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
revoked_at: { type: Sequelize.DATE, allowNull: true },
|
revoked_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
notes: { type: Sequelize.TEXT, 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 },
|
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
});
|
});
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
+13
@@ -1,5 +1,8 @@
|
|||||||
'use strict';
|
'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 = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable('advertisements', {
|
await queryInterface.createTable('advertisements', {
|
||||||
@@ -19,6 +22,10 @@ module.exports = {
|
|||||||
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
|
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
|
||||||
size: { type: Sequelize.ENUM('sm', 'md', 'lg'), allowNull: true },
|
size: { type: Sequelize.ENUM('sm', 'md', 'lg'), allowNull: true },
|
||||||
click_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
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 },
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
|
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
@@ -27,11 +34,17 @@ module.exports = {
|
|||||||
deletedAt: { type: Sequelize.DATE, allowNull: true },
|
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', ['uuid']);
|
||||||
await queryInterface.addIndex('advertisements', ['type']);
|
await queryInterface.addIndex('advertisements', ['type']);
|
||||||
await queryInterface.addIndex('advertisements', ['status']);
|
await queryInterface.addIndex('advertisements', ['status']);
|
||||||
await queryInterface.addIndex('advertisements', ['is_active']);
|
await queryInterface.addIndex('advertisements', ['is_active']);
|
||||||
await queryInterface.addIndex('advertisements', ['deletedAt']);
|
await queryInterface.addIndex('advertisements', ['deletedAt']);
|
||||||
|
await queryInterface.addIndex('advertisements', ['placement']);
|
||||||
},
|
},
|
||||||
|
|
||||||
async down(queryInterface) {
|
async down(queryInterface) {
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
+8
-2
@@ -1,18 +1,24 @@
|
|||||||
'use strict';
|
'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 = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable('task_requirements', {
|
await queryInterface.createTable('task_requirements', {
|
||||||
requirement_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
|
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' },
|
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_url: { type: Sequelize.STRING, allowNull: true },
|
||||||
link_label: { type: Sequelize.STRING, allowNull: true },
|
link_label: { type: Sequelize.STRING, allowNull: true },
|
||||||
allowed_file_types: { type: Sequelize.JSONB, allowNull: true },
|
allowed_file_types: { type: Sequelize.JSONB, allowNull: true },
|
||||||
max_file_count: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 1 },
|
max_file_count: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 1 },
|
||||||
reference_id: { type: Sequelize.UUID, allowNull: true },
|
reference_id: { type: Sequelize.UUID, allowNull: true },
|
||||||
reference_label: { type: Sequelize.STRING, 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 },
|
createdBy: { type: Sequelize.INTEGER, allowNull: true },
|
||||||
updatedBy: { type: Sequelize.INTEGER, allowNull: true },
|
updatedBy: { type: Sequelize.INTEGER, allowNull: true },
|
||||||
deletedBy: { type: Sequelize.INTEGER, allowNull: true },
|
deletedBy: { type: Sequelize.INTEGER, allowNull: true },
|
||||||
+10
@@ -7,6 +7,11 @@ module.exports = {
|
|||||||
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
|
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' },
|
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
|
||||||
note: { type: Sequelize.TEXT, allowNull: true },
|
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 },
|
submitted_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW, allowNull: false },
|
||||||
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
@@ -16,6 +21,11 @@ module.exports = {
|
|||||||
deletedAt: { type: Sequelize.DATE, allowNull: true },
|
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: ['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: ['user_id'], name: 'idx_tc_user_id' });
|
||||||
await queryInterface.addIndex('task_completions', { fields: ['task_id', 'user_id'], name: 'idx_tc_task_user' });
|
await queryInterface.addIndex('task_completions', { fields: ['task_id', 'user_id'], name: 'idx_tc_task_user' });
|
||||||
+9
-1
@@ -1,5 +1,8 @@
|
|||||||
'use strict';
|
'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 = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable('task_progress', {
|
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' },
|
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' },
|
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
|
||||||
reference_id: { type: Sequelize.UUID, allowNull: false },
|
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: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
|
||||||
completed_at: { type: Sequelize.DATE, allowNull: true },
|
completed_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
@@ -17,6 +20,11 @@ module.exports = {
|
|||||||
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
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: ['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: ['task_id'], name: 'idx_tp_task_id' });
|
||||||
await queryInterface.addIndex('task_progress', { fields: ['user_id'], name: 'idx_tp_user_id' });
|
await queryInterface.addIndex('task_progress', { fields: ['user_id'], name: 'idx_tp_user_id' });
|
||||||
+8
-2
@@ -1,5 +1,11 @@
|
|||||||
'use strict';
|
'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 = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.createTable('task_prerequisites', {
|
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', '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: ['task_id'], name: 'idx_tpq_task_id' });
|
||||||
await queryInterface.addIndex('task_prerequisites', { fields: ['prerequisite_task_id'], name: 'idx_tp_prerequisite_task_id' });
|
await queryInterface.addIndex('task_prerequisites', { fields: ['prerequisite_task_id'], name: 'idx_tpq_prerequisite_task_id' });
|
||||||
},
|
},
|
||||||
|
|
||||||
async down(queryInterface) {
|
async down(queryInterface) {
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user