chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
@@ -0,0 +1,31 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('users', {
user_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
email: { type: Sequelize.STRING(255), allowNull: false, unique: true },
password: { type: Sequelize.TEXT },
is_active: { type: Sequelize.BOOLEAN, defaultValue: true },
is_verified: { type: Sequelize.BOOLEAN, defaultValue: false },
reg_type: { type: Sequelize.ENUM('google', 'system'), defaultValue: 'system' },
acc_type: { type: Sequelize.ENUM('admin', 'staff', 'user'), defaultValue: 'user' },
needs_intro: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
personal_info: { type: Sequelize.JSONB, defaultValue: null },
must_change_password: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
password_expires_at: { type: Sequelize.DATE, allowNull: true },
otp_code: { type: Sequelize.STRING(6), allowNull: true },
otp_expires_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('users');
},
};
@@ -0,0 +1,48 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('assets', {
asset_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
original_name: { type: Sequelize.STRING(255), allowNull: false },
display_name: { type: Sequelize.STRING(255), allowNull: false },
file_url: { type: Sequelize.STRING(512), allowNull: false },
file_size: { type: Sequelize.BIGINT, allowNull: false },
mime_type: { type: Sequelize.STRING(100), allowNull: false },
extension: { type: Sequelize.STRING(20) },
checksum: { type: Sequelize.STRING(64) },
file_type: { type: Sequelize.ENUM('avatar', 'document', 'video', 'image', 'audio'), allowNull: false, defaultValue: 'image' },
width: { type: Sequelize.INTEGER },
height: { type: Sequelize.INTEGER },
duration: { type: Sequelize.FLOAT },
resolution: { type: Sequelize.STRING(20) },
frame_rate: { type: Sequelize.FLOAT },
bitrate: { type: Sequelize.BIGINT },
video_codec: { type: Sequelize.STRING(50) },
audio_codec: { type: Sequelize.STRING(50) },
thumbnail_url: { type: Sequelize.STRING(512) },
thumbnail_storage_key: { type: Sequelize.STRING(512), allowNull: true },
description: { type: Sequelize.TEXT },
storage_provider: { type: Sequelize.ENUM('local', 's3', 'gcs', 'cloudinary', 'chibisafe', 'other'), defaultValue: 'local' },
storage_bucket: { type: Sequelize.STRING(255) },
storage_key: { type: Sequelize.STRING(512) },
is_public: { type: Sequelize.BOOLEAN, defaultValue: false },
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('assets', ['uuid']);
await queryInterface.addIndex('assets', ['createdBy']);
await queryInterface.addIndex('assets', ['file_type']);
await queryInterface.addIndex('assets', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('assets');
},
};
@@ -0,0 +1,23 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_groups', {
group_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
name: { type: Sequelize.STRING(50), allowNull: false },
group_code: { type: Sequelize.STRING(50), allowNull: true, unique: true },
description: { type: Sequelize.TEXT },
is_active: { type: Sequelize.BOOLEAN, defaultValue: 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 },
});
},
async down(queryInterface) {
await queryInterface.dropTable('user_groups');
},
};
@@ -0,0 +1,26 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_sessions', {
session_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onUpdate: 'CASCADE', onDelete: 'CASCADE' },
login_info: { type: Sequelize.JSONB, allowNull: true },
logout_info: { type: Sequelize.JSONB, allowNull: true },
refresh_token_hash: { type: Sequelize.TEXT, allowNull: true },
is_active: { type: Sequelize.BOOLEAN, defaultValue: 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('user_sessions', ['user_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('user_sessions');
},
};
@@ -0,0 +1,24 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_group_members', {
group_id: { type: Sequelize.BIGINT, primaryKey: true, references: { model: 'user_groups', key: 'group_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, primaryKey: true, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
joined_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW },
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('user_group_members', ['user_id']);
await queryInterface.addIndex('user_group_members', ['group_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('user_group_members');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('achievements', {
achievement_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.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge' },
key: { type: Sequelize.STRING(100), allowNull: false },
label: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
icon: { type: Sequelize.STRING(50), allowNull: true },
granted_by: { type: Sequelize.BIGINT, allowNull: true },
granted_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
metadata: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('achievements', ['user_id']);
await queryInterface.addIndex('achievements', { fields: ['user_id', 'key'], unique: true, name: 'uq_achievement_user_key' });
},
async down(queryInterface) {
await queryInterface.dropTable('achievements');
},
};
@@ -0,0 +1,20 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('categories', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
name: { type: Sequelize.STRING(100), allowNull: false, unique: true },
slug: { type: Sequelize.STRING(120), allowNull: false, unique: true },
description: { type: Sequelize.TEXT, allowNull: true },
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('categories');
},
};
@@ -0,0 +1,43 @@
'use strict';
// subscription/status are STRING + explicit CHECK constraints, not native
// Sequelize.ENUM — matches the live schema after 20260101000058 collapsed
// `subscription` from ENUM to a CockroachDB-compatible VARCHAR+CHECK column,
// and 20260710000001 added `status` the same way from the start.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('courses', {
course_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 },
description: { type: Sequelize.TEXT, allowNull: true },
course_code: { type: Sequelize.STRING(50), allowNull: true, unique: true },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
level: { type: Sequelize.ENUM('beginner', 'intermediate', 'advanced'), allowNull: true },
subscription: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'free' },
status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'draft' },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
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.sequelize.query(
`ALTER TABLE courses ADD CONSTRAINT check_subscription CHECK (subscription IN ('free', 'premium', 'exclusive'))`
);
await queryInterface.sequelize.query(
`ALTER TABLE courses ADD CONSTRAINT check_status CHECK (status IN ('draft', 'published', 'unpublished'))`
);
await queryInterface.addIndex('courses', ['uuid']);
await queryInterface.addIndex('courses', ['subscription']);
await queryInterface.addIndex('courses', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('courses');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_objectives', {
objective_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
text: { type: Sequelize.TEXT, allowNull: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('course_objectives', ['course_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('course_objectives');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_prerequisites', {
prereq_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
ref_type: { type: Sequelize.ENUM('course', 'unit', 'lesson'), allowNull: false },
ref_id: { type: Sequelize.BIGINT, allowNull: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('course_prerequisites', ['course_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('course_prerequisites');
},
};
@@ -0,0 +1,26 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_assessments', {
assessment_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
title: { type: Sequelize.STRING(255), allowNull: true },
is_required: { type: Sequelize.BOOLEAN, defaultValue: false },
passing_score: { type: Sequelize.INTEGER, defaultValue: 70 },
time_limit_minutes: { type: Sequelize.INTEGER, allowNull: true },
max_questions: { type: Sequelize.INTEGER, 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 },
});
},
async down(queryInterface) {
await queryInterface.dropTable('course_assessments');
},
};
@@ -0,0 +1,30 @@
'use strict';
// units no longer holds a direct course_id / order_index — the junction
// revamp (originally 20260707000001) moved course<->unit attachment (and its
// ordering) onto course_units, so units can now be standalone.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('units', {
unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
title: { type: Sequelize.STRING(255), allowNull: false },
subscription: { type: Sequelize.STRING(50), allowNull: true },
description: { type: Sequelize.TEXT, allowNull: true },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, 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('units', ['uuid']);
await queryInterface.addIndex('units', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('units');
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('unit_quizzes', {
quiz_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
unit_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
title: { type: Sequelize.STRING(255), allowNull: true },
is_required: { type: Sequelize.BOOLEAN, defaultValue: false },
passing_score: { type: Sequelize.INTEGER, defaultValue: 70 },
max_questions: { type: Sequelize.INTEGER, 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 },
});
},
async down(queryInterface) {
await queryInterface.dropTable('unit_quizzes');
},
};
@@ -0,0 +1,28 @@
'use strict';
// lessons no longer holds a direct unit_id / order_index — moved onto
// unit_lessons by the junction revamp (originally 20260707000001).
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lessons', {
lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
title: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, 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('lessons', ['uuid']);
await queryInterface.addIndex('lessons', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('lessons');
},
};
@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lesson_pages', {
page_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
lesson_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
blocks: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
async down(queryInterface) {
await queryInterface.dropTable('lesson_pages');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lesson_objectives', {
objective_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
text: { type: Sequelize.TEXT, allowNull: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('lesson_objectives', ['lesson_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('lesson_objectives');
},
};
@@ -0,0 +1,29 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_questions', {
question_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
quiz_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'unit_quizzes', key: 'quiz_id' }, onDelete: 'CASCADE' },
assessment_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'CASCADE' },
type: { type: Sequelize.ENUM('true_false', 'multiple_choice', 'multi_select'), allowNull: false },
question: { type: Sequelize.TEXT, allowNull: false },
explanation: { type: Sequelize.TEXT, allowNull: true },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
points: { type: Sequelize.INTEGER, defaultValue: 1 },
createdBy: { 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('quiz_questions', ['quiz_id']);
await queryInterface.addIndex('quiz_questions', ['assessment_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('quiz_questions');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_options', {
option_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
question_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'quiz_questions', key: 'question_id' }, onDelete: 'CASCADE' },
text: { type: Sequelize.TEXT, allowNull: false },
is_correct: { type: Sequelize.BOOLEAN, defaultValue: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('quiz_options', ['question_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('quiz_options');
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('products', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
name: { type: Sequelize.STRING(200), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
currency: { type: Sequelize.STRING(10), allowNull: false, defaultValue: 'USD' },
access_days: { type: Sequelize.INTEGER, allowNull: true }, // null = lifetime access
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 },
});
await queryInterface.addIndex('products', ['course_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('products');
},
};
@@ -0,0 +1,29 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_purchases', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
product_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'products', key: 'id' }, onDelete: 'RESTRICT' },
amount: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
currency: { type: Sequelize.STRING(10), allowNull: false, defaultValue: 'USD' },
status: { type: Sequelize.ENUM('pending', 'completed', 'failed', 'cancelled', 'refunded'), allowNull: false, defaultValue: 'pending' },
provider: { type: Sequelize.STRING(50), allowNull: false, defaultValue: 'paypal' },
provider_payload: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
expires_at: { type: Sequelize.DATE, allowNull: true }, // null = lifetime access
paid_at: { type: Sequelize.DATE, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('course_purchases', ['user_id']);
await queryInterface.addIndex('course_purchases', ['product_id']);
await queryInterface.addIndex('course_purchases', ['status']);
},
async down(queryInterface) {
await queryInterface.dropTable('course_purchases');
},
};
@@ -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,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_roles', {
role_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
text: { type: Sequelize.STRING(255), allowNull: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('course_roles', ['course_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('course_roles');
},
};
@@ -0,0 +1,36 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('tier_categories', {
tier_category_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
slug: { type: Sequelize.STRING(50), allowNull: false, unique: true },
name: { type: Sequelize.STRING(100), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
rank: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
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: true },
is_default: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
// Seed the three baseline tier categories
await queryInterface.sequelize.query(`
INSERT INTO tier_categories (slug, name, rank, is_default, is_active, "createdAt", "updatedAt")
VALUES
('free', 'Free', 0, true, true, NOW(), NOW()),
('premium', 'Premium', 1, false, true, NOW(), NOW()),
('exclusive', 'Exclusive', 2, false, true, NOW(), NOW())
`);
},
async down(queryInterface) {
await queryInterface.dropTable('tier_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');
},
};
@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('plan_courses', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'tier_plans', key: 'plan_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('plan_courses', ['plan_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('plan_courses');
},
};
@@ -0,0 +1,31 @@
'use strict';
// `tier` started as ENUM('free','premium','exclusive') but 20260101000065
// dropped its CHECK constraint entirely — free-form STRING now, matching
// any tier_categories.slug value.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_tiers', {
tier_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
tier: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'free' },
status: { type: Sequelize.ENUM('active', 'expired', 'revoked'), allowNull: false, defaultValue: 'active' },
starts_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
expires_at: { type: Sequelize.DATE, allowNull: true },
granted_by: { type: Sequelize.BIGINT, allowNull: true },
revoked_by: { type: Sequelize.BIGINT, allowNull: true },
revoked_at: { type: Sequelize.DATE, allowNull: true },
notes: { type: Sequelize.TEXT, allowNull: true },
plan_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'tier_plans', key: 'plan_id' }, onUpdate: 'CASCADE', onDelete: 'SET NULL' },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('user_tiers', ['user_id']);
await queryInterface.addIndex('user_tiers', ['status']);
},
async down(queryInterface) {
await queryInterface.dropTable('user_tiers');
},
};
@@ -0,0 +1,34 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('payments', {
payment_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
plan_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'tier_plans', key: 'plan_id' }, onDelete: 'RESTRICT' },
tier_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'user_tiers', key: 'tier_id' }, onDelete: 'SET NULL' },
status: { type: Sequelize.ENUM('pending', 'completed', 'failed', 'cancelled', 'expired', 'refunded'), allowNull: false, defaultValue: 'pending' },
amount: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
currency: { type: Sequelize.STRING(10), allowNull: false, defaultValue: 'USD' },
promo_code: { type: Sequelize.STRING(50), allowNull: true },
discount: { type: Sequelize.DECIMAL(10, 2), allowNull: false, defaultValue: 0.00 },
provider: { type: Sequelize.STRING(50), allowNull: false, defaultValue: 'paypal' },
provider_payload: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
paid_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('payments', ['user_id']);
await queryInterface.addIndex('payments', ['status']);
await queryInterface.addIndex('payments', ['plan_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('payments');
},
};
@@ -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('payment_policies', {
policy_id: {
type: Sequelize.BIGINT,
primaryKey: true,
autoIncrement: true,
},
plan_id: {
type: Sequelize.BIGINT,
allowNull: true,
unique: true,
references: { model: 'tier_plans', key: 'plan_id' },
onDelete: 'CASCADE',
},
// Array of { code, type:'flat'|'percent', value, currency?, max_discount?,
// max_uses?, expires_at?, min_amount? }
promo_rules: {
type: Sequelize.JSONB,
allowNull: false,
defaultValue: [],
},
// { allowed, window_value, window_unit:'minutes'|'hours'|'days', reason_required }
refund_policy: {
type: Sequelize.JSONB,
allowNull: false,
defaultValue: { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false },
},
// e.g. ["paypal"]
allowed_providers: {
type: Sequelize.JSONB,
allowNull: false,
defaultValue: ['paypal'],
},
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('payment_policies', ['plan_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('payment_policies');
},
};
@@ -0,0 +1,24 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('system_badges', {
badge_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
key: { type: Sequelize.STRING(50), allowNull: false, unique: true },
asset_id: { type: Sequelize.BIGINT, allowNull: true,
references: { model: 'assets', key: 'asset_id' },
onUpdate: 'CASCADE', onDelete: 'SET NULL' },
label: { type: Sequelize.STRING(100), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
information: { type: Sequelize.TEXT, allowNull: true },
active_from: { type: Sequelize.DATEONLY, allowNull: true },
active_until: { type: Sequelize.DATEONLY, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
async down(queryInterface) {
await queryInterface.dropTable('system_badges');
},
};
@@ -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');
},
};
@@ -0,0 +1,53 @@
'use strict';
// content_mode is STRING + explicit CHECK (CockroachDB can't create a new
// enum type via addColumn the way createTable can) — matches the live schema
// since 20260711000003.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('advertisements', {
advertisement_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
type: { type: Sequelize.ENUM('hero', 'banner', 'popup', 'sidebar'), allowNull: false },
status: { type: Sequelize.ENUM('draft', 'active', 'scheduled', 'expired', 'archived'), allowNull: false, defaultValue: 'draft' },
badge_label: { type: Sequelize.STRING(100) },
headline: { type: Sequelize.STRING(255) },
description: { type: Sequelize.TEXT },
image_url: { type: Sequelize.STRING(512) },
image_asset_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'assets', key: 'asset_id' }, onDelete: 'SET NULL' },
ctas: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] },
start_date: { type: Sequelize.DATE },
end_date: { type: Sequelize.DATE },
order: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
size: { type: Sequelize.ENUM('sm', 'md', 'lg'), allowNull: true },
click_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
placement: { type: Sequelize.STRING(100), allowNull: true },
content_mode: { type: Sequelize.STRING, allowNull: false, defaultValue: 'image' },
redirect_link: { type: Sequelize.STRING(512), allowNull: true },
landing_page: { type: Sequelize.JSONB, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.sequelize.query(`
ALTER TABLE advertisements ADD CONSTRAINT check_content_mode
CHECK (content_mode IN ('image', 'content'))
`);
await queryInterface.addIndex('advertisements', ['uuid']);
await queryInterface.addIndex('advertisements', ['type']);
await queryInterface.addIndex('advertisements', ['status']);
await queryInterface.addIndex('advertisements', ['is_active']);
await queryInterface.addIndex('advertisements', ['deletedAt']);
await queryInterface.addIndex('advertisements', ['placement']);
},
async down(queryInterface) {
await queryInterface.dropTable('advertisements');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_lists', {
task_list_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
name: { type: Sequelize.STRING, allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
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 },
});
},
async down(queryInterface) {
await queryInterface.dropTable('task_lists');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_list_groups', {
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' },
group_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'user_groups', key: 'group_id' }, onDelete: 'CASCADE' },
assignedAt: { type: Sequelize.DATE, defaultValue: Sequelize.NOW, allowNull: false },
assignedBy: { type: Sequelize.BIGINT, allowNull: true },
});
await queryInterface.addIndex('task_list_groups', { fields: ['task_list_id', 'group_id'], unique: true, name: 'uq_task_list_group' });
await queryInterface.addIndex('task_list_groups', { fields: ['task_list_id'], name: 'idx_tlg_task_list_id' });
await queryInterface.addIndex('task_list_groups', { fields: ['group_id'], name: 'idx_tlg_group_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_list_groups');
},
};
@@ -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');
},
};
@@ -0,0 +1,36 @@
'use strict';
// The live enum type backing `type` is named task_requirement_type (not the
// Sequelize-default enum_task_requirements_type) — submit_text was added to
// it later via ALTER TYPE (20260715000001). Declared here with the full
// merged value set.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_requirements', {
requirement_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
type: { type: Sequelize.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson', 'submit_text'), allowNull: false },
link_url: { type: Sequelize.STRING, allowNull: true },
link_label: { type: Sequelize.STRING, allowNull: true },
allowed_file_types: { type: Sequelize.JSONB, allowNull: true },
max_file_count: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 1 },
reference_id: { type: Sequelize.UUID, allowNull: true },
reference_label: { type: Sequelize.STRING, allowNull: true },
prompt: { type: Sequelize.TEXT, allowNull: true },
requires_review: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
order: { type: Sequelize.INTEGER, defaultValue: 0 },
createdBy: { type: Sequelize.INTEGER, allowNull: true },
updatedBy: { type: Sequelize.INTEGER, allowNull: true },
deletedBy: { type: Sequelize.INTEGER, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('task_requirements', ['task_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('task_requirements');
},
};
@@ -0,0 +1,37 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_completions', {
completion_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
note: { type: Sequelize.TEXT, allowNull: true },
response_text: { type: Sequelize.TEXT, allowNull: true },
status: { type: Sequelize.STRING, allowNull: false, defaultValue: 'submitted' },
reviewed_by: { type: Sequelize.BIGINT, allowNull: true },
reviewed_at: { type: Sequelize.DATE, allowNull: true },
review_note: { type: Sequelize.TEXT, allowNull: true },
submitted_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW, allowNull: false },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
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.sequelize.query(
`ALTER TABLE task_completions ADD CONSTRAINT check_status
CHECK (status IN ('submitted', 'approved', 'rejected'))`
);
await queryInterface.addIndex('task_completions', { fields: ['task_id'], name: 'idx_tc_task_id' });
await queryInterface.addIndex('task_completions', { fields: ['user_id'], name: 'idx_tc_user_id' });
await queryInterface.addIndex('task_completions', { fields: ['task_id', 'user_id'], name: 'idx_tc_task_user' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_completions');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_completion_files', {
file_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
completion_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_completions', key: 'completion_id' }, onDelete: 'CASCADE' },
file_url: { type: Sequelize.STRING, allowNull: false },
file_name: { type: Sequelize.STRING, allowNull: false },
file_size: { type: Sequelize.INTEGER, allowNull: true },
mime_type: { type: Sequelize.STRING, allowNull: true },
storage_key: { type: Sequelize.STRING(500), 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('task_completion_files', { fields: ['completion_id'], name: 'idx_tcf_completion_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_completion_files');
},
};
@@ -0,0 +1,26 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_link_visits', {
visit_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_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' },
visited_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW, allowNull: false },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('task_link_visits', { fields: ['requirement_id', 'user_id'], unique: true, name: 'uq_tlv_requirement_user' });
await queryInterface.addIndex('task_link_visits', { fields: ['task_id'], name: 'idx_tlv_task_id' });
await queryInterface.addIndex('task_link_visits', { fields: ['user_id'], name: 'idx_tlv_user_id' });
await queryInterface.addIndex('task_link_visits', { fields: ['requirement_id'], name: 'idx_tlv_requirement_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_link_visits');
},
};
@@ -0,0 +1,37 @@
'use strict';
// `type` is STRING + an explicit CHECK constraint (check_type) — this
// column isn't a native enum on this DB.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_progress', {
progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_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' },
reference_id: { type: Sequelize.UUID, allowNull: false },
type: { type: Sequelize.STRING, allowNull: false },
completed: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
completed_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { 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'))`
);
await queryInterface.addIndex('task_progress', { fields: ['requirement_id', 'user_id', 'reference_id'], unique: true, name: 'uq_tp_requirement_user_reference' });
await queryInterface.addIndex('task_progress', { fields: ['task_id'], name: 'idx_tp_task_id' });
await queryInterface.addIndex('task_progress', { fields: ['user_id'], name: 'idx_tp_user_id' });
await queryInterface.addIndex('task_progress', { fields: ['requirement_id'], name: 'idx_tp_requirement_id' });
await queryInterface.addIndex('task_progress', { fields: ['type'], name: 'idx_tp_type' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_progress');
},
};
@@ -0,0 +1,27 @@
'use strict';
// Index names use a `tpq_` (task-prerequisite) prefix instead of the
// original migration's `tp_` prefix — the original names (idx_tp_task_id,
// idx_tp_prerequisite_task_id) collide with task_progress's own
// idx_tp_task_id (see 20270101000040). CockroachDB apparently tolerates
// duplicate index names across tables, but Postgres does not, so this is
// fixed here rather than replicated — flagged for Kenneth's awareness.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_prerequisites', {
id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
prerequisite_task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
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_tpq_task_id' });
await queryInterface.addIndex('task_prerequisites', { fields: ['prerequisite_task_id'], name: 'idx_tpq_prerequisite_task_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_prerequisites');
},
};
@@ -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');
},
};
@@ -0,0 +1,35 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_reading_progress', {
progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
reference_id: { type: Sequelize.UUID, allowNull: false },
type: { type: Sequelize.ENUM('course', 'unit', 'lesson'), allowNull: false },
status: { type: Sequelize.ENUM('in_progress', 'completed'), allowNull: false, defaultValue: 'in_progress' },
completed_at: { type: Sequelize.DATE, allowNull: true },
last_accessed_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('course_reading_progress', {
fields: ['user_id', 'type', 'reference_id'],
unique: true,
name: 'uq_crp_user_type_reference',
});
await queryInterface.addIndex('course_reading_progress', { fields: ['user_id'], name: 'idx_crp_user_id' });
await queryInterface.addIndex('course_reading_progress', { fields: ['course_id'], name: 'idx_crp_course_id' });
await queryInterface.addIndex('course_reading_progress', { fields: ['type'], name: 'idx_crp_type' });
await queryInterface.addIndex('course_reading_progress', { fields: ['status'], name: 'idx_crp_status' });
},
async down(queryInterface) {
await queryInterface.dropTable('course_reading_progress');
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_units', {
course_unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
});
await queryInterface.addIndex('course_units', { fields: ['course_id', 'unit_id'], unique: true, name: 'uq_course_units_course_unit' });
await queryInterface.addIndex('course_units', { fields: ['course_id'], name: 'idx_course_units_course_id' });
await queryInterface.addIndex('course_units', { fields: ['unit_id'], name: 'idx_course_units_unit_id' });
await queryInterface.addIndex('course_units', { fields: ['order_index'], name: 'idx_course_units_order' });
},
async down(queryInterface) {
await queryInterface.dropTable('course_units');
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('unit_lessons', {
unit_lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
});
await queryInterface.addIndex('unit_lessons', { fields: ['unit_id', 'lesson_id'], unique: true, name: 'uq_unit_lessons_unit_lesson' });
await queryInterface.addIndex('unit_lessons', { fields: ['unit_id'], name: 'idx_unit_lessons_unit_id' });
await queryInterface.addIndex('unit_lessons', { fields: ['lesson_id'], name: 'idx_unit_lessons_lesson_id' });
await queryInterface.addIndex('unit_lessons', { fields: ['order_index'], name: 'idx_unit_lessons_order' });
},
async down(queryInterface) {
await queryInterface.dropTable('unit_lessons');
},
};
@@ -0,0 +1,36 @@
'use strict';
// course_id is nullable — the junction revamp (20260707000001) allows
// standalone (course-less) unit reads.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('unit_reading_progress', {
progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
status: { type: Sequelize.ENUM('in_progress', 'completed'), allowNull: false, defaultValue: 'in_progress' },
completed_at: { type: Sequelize.DATE, allowNull: true },
last_accessed_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('unit_reading_progress', {
fields: ['user_id', 'unit_id'],
unique: true,
name: 'uq_urp_user_unit',
});
await queryInterface.addIndex('unit_reading_progress', { fields: ['user_id'], name: 'idx_urp_user_id' });
await queryInterface.addIndex('unit_reading_progress', { fields: ['course_id'], name: 'idx_urp_course_id' });
await queryInterface.addIndex('unit_reading_progress', { fields: ['unit_id'], name: 'idx_urp_unit_id' });
await queryInterface.addIndex('unit_reading_progress', { fields: ['status'], name: 'idx_urp_status' });
},
async down(queryInterface) {
await queryInterface.dropTable('unit_reading_progress');
},
};
@@ -0,0 +1,38 @@
'use strict';
// course_id and unit_id are nullable — the junction revamp (20260707000001)
// allows standalone (course-less / unit-less) lesson reads.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lesson_reading_progress', {
progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
unit_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
status: { type: Sequelize.ENUM('in_progress', 'completed'), allowNull: false, defaultValue: 'in_progress' },
completed_at: { type: Sequelize.DATE, allowNull: true },
last_accessed_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('lesson_reading_progress', {
fields: ['user_id', 'lesson_id'],
unique: true,
name: 'uq_lrp_user_lesson',
});
await queryInterface.addIndex('lesson_reading_progress', { fields: ['user_id'], name: 'idx_lrp_user_id' });
await queryInterface.addIndex('lesson_reading_progress', { fields: ['course_id'], name: 'idx_lrp_course_id' });
await queryInterface.addIndex('lesson_reading_progress', { fields: ['unit_id'], name: 'idx_lrp_unit_id' });
await queryInterface.addIndex('lesson_reading_progress', { fields: ['lesson_id'], name: 'idx_lrp_lesson_id' });
await queryInterface.addIndex('lesson_reading_progress', { fields: ['status'], name: 'idx_lrp_status' });
},
async down(queryInterface) {
await queryInterface.dropTable('lesson_reading_progress');
},
};
@@ -0,0 +1,34 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('pages', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
type: {
type: Sequelize.ENUM('landing_page', 'lesson_page', 'course_page'),
allowNull: false,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await queryInterface.addIndex('pages', { fields: ['type'], name: 'idx_pages_type' });
},
async down(queryInterface) {
await queryInterface.dropTable('pages');
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_pages_type";');
},
};
@@ -0,0 +1,69 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('landing_pages', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
page_id: {
type: Sequelize.BIGINT,
allowNull: false,
unique: true,
references: { model: 'pages', key: 'id' },
onDelete: 'CASCADE',
},
title: {
type: Sequelize.STRING(255),
allowNull: false,
},
slug: {
type: Sequelize.STRING(255),
allowNull: false,
unique: true,
},
meta_title: {
type: Sequelize.STRING(255),
allowNull: true,
},
meta_description: {
type: Sequelize.TEXT,
allowNull: true,
},
status: {
type: Sequelize.ENUM('draft', 'published', 'archived'),
allowNull: false,
defaultValue: 'draft',
},
published_at: {
type: Sequelize.DATE,
allowNull: true,
},
created_by: {
type: Sequelize.BIGINT,
allowNull: true,
references: { model: 'users', key: 'user_id' },
onDelete: 'SET NULL',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await queryInterface.addIndex('landing_pages', { fields: ['slug'], name: 'idx_landing_pages_slug' });
await queryInterface.addIndex('landing_pages', { fields: ['status'], name: 'idx_landing_pages_status' });
},
async down(queryInterface) {
await queryInterface.dropTable('landing_pages');
},
};
@@ -0,0 +1,59 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('page_sections', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
page_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'pages', key: 'id' },
onDelete: 'CASCADE',
},
type: {
type: Sequelize.ENUM('hero', 'features', 'cta', 'testimonials', 'faq', 'pricing', 'gallery', 'custom'),
allowNull: false,
},
label: {
type: Sequelize.STRING(255),
allowNull: true,
},
position: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
settings: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Background, padding, layout, and other section-level style overrides.',
},
is_visible: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await queryInterface.addIndex('page_sections', { fields: ['page_id'], name: 'idx_page_sections_page_id' });
await queryInterface.addIndex('page_sections', { fields: ['page_id', 'position'], name: 'idx_page_sections_order' });
},
async down(queryInterface) {
await queryInterface.dropTable('page_sections');
},
};
@@ -0,0 +1,55 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('page_blocks', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
page_section_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'page_sections', key: 'id' },
onDelete: 'CASCADE',
},
type: {
type: Sequelize.ENUM('text', 'image', 'button', 'video', 'form', 'spacer'),
allowNull: false,
},
content: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Block payload — varies by type (e.g. src + alt for image, href + label for button).',
},
position: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
settings: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Block-level style overrides: alignment, color, font size, etc.',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await queryInterface.addIndex('page_blocks', { fields: ['page_section_id'], name: 'idx_page_blocks_section_id' });
await queryInterface.addIndex('page_blocks', { fields: ['page_section_id', 'position'], name: 'idx_page_blocks_order' });
},
async down(queryInterface) {
await queryInterface.dropTable('page_blocks');
},
};
@@ -0,0 +1,35 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('page_templates', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
name: {
type: Sequelize.STRING(255),
allowNull: false,
},
thumbnail_url: {
type: Sequelize.TEXT,
allowNull: true,
},
structure: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Full snapshot of sections and blocks for this template.',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
},
async down(queryInterface) {
await queryInterface.dropTable('page_templates');
},
};
@@ -0,0 +1,67 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('announcements', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
title: {
type: Sequelize.STRING(255),
allowNull: false,
},
body: {
type: Sequelize.TEXT,
allowNull: false,
},
scope: {
type: Sequelize.ENUM('global', 'course', 'role'),
allowNull: false,
defaultValue: 'global',
},
priority: {
type: Sequelize.ENUM('low', 'normal', 'urgent'),
allowNull: false,
defaultValue: 'normal',
},
published_by: {
type: Sequelize.BIGINT,
allowNull: true,
references: { model: 'users', key: 'user_id' },
onDelete: 'SET NULL',
},
starts_at: {
type: Sequelize.DATE,
allowNull: true,
},
ends_at: {
type: Sequelize.DATE,
allowNull: true,
},
is_active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await queryInterface.addIndex('announcements', { fields: ['scope'], name: 'idx_announcements_scope' });
await queryInterface.addIndex('announcements', { fields: ['is_active'], name: 'idx_announcements_is_active' });
},
async down(queryInterface) {
await queryInterface.dropTable('announcements');
},
};
@@ -0,0 +1,38 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('announcement_courses', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
announcement_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'announcements', key: 'id' },
onDelete: 'CASCADE',
},
course_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'courses', key: 'course_id' },
onDelete: 'CASCADE',
},
});
await queryInterface.addConstraint('announcement_courses', {
fields: ['announcement_id', 'course_id'],
type: 'unique',
name: 'uq_announcement_courses',
});
await queryInterface.addIndex('announcement_courses', { fields: ['announcement_id'], name: 'idx_announcement_courses_ann_id' });
await queryInterface.addIndex('announcement_courses', { fields: ['course_id'], name: 'idx_announcement_courses_course_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('announcement_courses');
},
};
@@ -0,0 +1,35 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('announcement_roles', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
announcement_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'announcements', key: 'id' },
onDelete: 'CASCADE',
},
role: {
type: Sequelize.STRING(50),
allowNull: false,
},
});
await queryInterface.addConstraint('announcement_roles', {
fields: ['announcement_id', 'role'],
type: 'unique',
name: 'uq_announcement_roles',
});
await queryInterface.addIndex('announcement_roles', { fields: ['announcement_id'], name: 'idx_announcement_roles_ann_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('announcement_roles');
},
};
@@ -0,0 +1,43 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('announcement_reads', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
announcement_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'announcements', key: 'id' },
onDelete: 'CASCADE',
},
user_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'users', key: 'user_id' },
onDelete: 'CASCADE',
},
read_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await queryInterface.addConstraint('announcement_reads', {
fields: ['announcement_id', 'user_id'],
type: 'unique',
name: 'uq_announcement_reads',
});
await queryInterface.addIndex('announcement_reads', { fields: ['announcement_id'], name: 'idx_announcement_reads_ann_id' });
await queryInterface.addIndex('announcement_reads', { fields: ['user_id'], name: 'idx_announcement_reads_user_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('announcement_reads');
},
};
@@ -0,0 +1,24 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_activity', {
activity_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
session_id: { type: Sequelize.BIGINT, allowNull: true },
action: { type: Sequelize.STRING(100), allowNull: false },
entity_type: { type: Sequelize.STRING(50), allowNull: true },
entity_id: { type: Sequelize.BIGINT, allowNull: true },
details: { type: Sequelize.JSONB, allowNull: true },
created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') },
});
await queryInterface.addIndex('user_activity', { fields: ['user_id'], name: 'idx_ua_user_id' });
await queryInterface.addIndex('user_activity', { fields: ['action'], name: 'idx_ua_action' });
await queryInterface.addIndex('user_activity', { fields: ['created_at'], name: 'idx_ua_created_at' });
},
async down(queryInterface) {
await queryInterface.dropTable('user_activity');
},
};
@@ -0,0 +1,33 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('page_user_groups', {
page_id: {
type: Sequelize.BIGINT,
allowNull: false,
primaryKey: true,
references: { model: 'pages', key: 'id' },
onDelete: 'CASCADE',
},
group_id: {
type: Sequelize.BIGINT,
allowNull: false,
primaryKey: true,
references: { model: 'user_groups', key: 'group_id' },
onDelete: 'CASCADE',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await queryInterface.addIndex('page_user_groups', { fields: ['group_id'], name: 'idx_page_user_groups_group_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('page_user_groups');
},
};
@@ -0,0 +1,29 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('assessment_sessions', {
session_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true, allowNull: false },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
assessment_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: true },
started_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
expires_at: { type: Sequelize.DATE, allowNull: true },
status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'in_progress' },
attempt_id: { type: Sequelize.BIGINT, allowNull: true },
draft_answers: { type: Sequelize.JSONB, allowNull: true, defaultValue: null },
last_heartbeat_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('assessment_sessions', ['user_id'], { name: 'idx_as_user_id' });
await queryInterface.addIndex('assessment_sessions', ['assessment_id'], { name: 'idx_as_assessment_id' });
await queryInterface.addIndex('assessment_sessions', ['status'], { name: 'idx_as_status' });
},
async down(queryInterface) {
await queryInterface.dropTable('assessment_sessions');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('pending_certificates', {
pending_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: false },
course_uuid: { type: Sequelize.STRING(36), allowNull: false },
course_title: { type: Sequelize.TEXT, allowNull: true },
passed_at: { type: Sequelize.DATE, allowNull: false },
issue_at: { type: Sequelize.DATE, allowNull: false },
processed_at: { type: Sequelize.DATE, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('pending_certificates', ['user_id'], { name: 'idx_pc_user_id' });
await queryInterface.addIndex('pending_certificates', ['issue_at'], { name: 'idx_pc_issue_at' });
await queryInterface.addIndex('pending_certificates', ['processed_at'], { name: 'idx_pc_processed_at' });
await queryInterface.addIndex('pending_certificates', ['user_id', 'course_id'], { name: 'idx_pc_user_course', unique: true });
},
async down(queryInterface) {
await queryInterface.dropTable('pending_certificates');
},
};
@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('cron_notification_settings', {
job_name: { type: Sequelize.STRING(64), primaryKey: true },
enabled: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
schedule: { type: Sequelize.STRING(20), allowNull: false },
target_status: { type: Sequelize.STRING(20), allowNull: true, defaultValue: null },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
async down(queryInterface) {
await queryInterface.dropTable('cron_notification_settings');
},
};
@@ -0,0 +1,40 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('achievement_definitions', {
achievement_definition_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
key: { type: Sequelize.STRING(100), allowNull: false, unique: true },
type: { type: Sequelize.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge' },
label: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
icon: { type: Sequelize.STRING(50), allowNull: true },
trigger: { type: Sequelize.STRING(30), allowNull: true },
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
// Seed the 9 built-in keys that services/achievements.service.js's trigger
// functions reference by name — these are marked is_system so they can't be
// deleted/renamed from the admin CRUD.
await queryInterface.sequelize.query(`
INSERT INTO achievement_definitions (key, type, label, description, icon, trigger, is_active, is_system, "createdAt", "updatedAt")
VALUES
('early_access', 'badge', 'Early Access', 'Registered during the Philproperties beta period (before Dec 31, 2026).', 'Star', 'auth', true, true, NOW(), NOW()),
('premium_first_time', 'badge', 'Premium Member', 'Purchased a Premium tier plan for the first time.', 'BadgeCheck', 'tier', true, true, NOW(), NOW()),
('exclusive_first_time', 'badge', 'Exclusive Member', 'Purchased an Exclusive tier plan for the first time.', 'Medal', 'tier', true, true, NOW(), NOW()),
('first_course_completed', 'milestone', 'First Course Completed', 'Completed your very first course on Philproperties.', 'BookOpen', 'course', true, true, NOW(), NOW()),
('courses_completed_5', 'milestone', 'Learning Streak', 'Completed 5 courses.', 'Flame', 'course', true, true, NOW(), NOW()),
('courses_completed_10', 'milestone', 'Knowledge Builder', 'Completed 10 courses.', 'Zap', 'course', true, true, NOW(), NOW()),
('perfect_quiz_score', 'milestone', 'Perfect Score', 'Achieved a perfect score on a quiz.', 'Target', 'course', true, true, NOW(), NOW()),
('profile_completed', 'milestone', 'Profile Complete', 'Filled out all personal profile information.', 'Shield', 'profile', true, true, NOW(), NOW()),
('first_referral', 'milestone', 'Referral Champion', 'Successfully referred a user to Philproperties.', 'Award', 'social', true, true, NOW(), NOW())
`);
},
async down(queryInterface) {
await queryInterface.dropTable('achievement_definitions');
},
};
@@ -0,0 +1,31 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('trusted_devices', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onUpdate: 'CASCADE', onDelete: 'CASCADE' },
device_token_hash: { type: Sequelize.TEXT, allowNull: false },
fingerprint_hash: { type: Sequelize.TEXT, allowNull: false },
last_session_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'user_sessions', key: 'session_id' }, onUpdate: 'CASCADE', onDelete: 'SET NULL' },
expires_at: { type: Sequelize.DATE, allowNull: false },
revoked_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('trusted_devices', ['user_id']);
await queryInterface.addIndex('trusted_devices', ['user_id', 'fingerprint_hash'], {
unique: true,
name: 'trusted_devices_user_fingerprint_unique',
});
},
async down(queryInterface) {
await queryInterface.dropTable('trusted_devices');
},
};
@@ -0,0 +1,41 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Single shared banner image for the rotating sticky announcement bar —
// one image for all (up to 3) concurrently-active announcements, not one
// per announcement. Enforced as a singleton in application code (always
// read/written as id=1) — just a one-row setting.
await queryInterface.createTable('sticky_banner_settings', {
id: {
type: Sequelize.SMALLINT,
primaryKey: true,
defaultValue: 1,
},
image_asset_id: {
type: Sequelize.BIGINT,
allowNull: true,
references: { model: 'assets', key: 'asset_id' },
onDelete: 'SET NULL',
},
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'),
},
});
},
async down(queryInterface) {
await queryInterface.dropTable('sticky_banner_settings');
},
};
@@ -0,0 +1,41 @@
'use strict';
// Raw SQL — Sequelize's createTable() wraps ENUM creation in a `DO $$...$$`
// block for idempotency, which CockroachDB rejects ("CREATE TYPE usage
// inside a function definition is not supported"). STRING + CHECK instead,
// same pattern used throughout this schema for CRDB compatibility.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE TABLE completion_requirements (
requirement_id UUID PRIMARY KEY,
entity_type STRING NOT NULL,
entity_id BIGINT NOT NULL,
type STRING NOT NULL,
min_percent INTEGER NULL,
button_label STRING NULL,
is_required BOOLEAN NOT NULL DEFAULT true,
"order" INTEGER NOT NULL DEFAULT 0,
"createdBy" BIGINT NULL,
"updatedBy" BIGINT NULL,
"deletedBy" BIGINT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMPTZ NULL,
CONSTRAINT check_cr_entity_type CHECK (entity_type IN ('course', 'unit', 'lesson')),
CONSTRAINT check_cr_type CHECK (type IN ('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete', 'watch_video', 'listen_audio'))
);
`);
await queryInterface.sequelize.query(
`CREATE INDEX idx_cr_entity_type_entity_id ON completion_requirements (entity_type, entity_id);`
);
await queryInterface.sequelize.query(
`CREATE INDEX idx_cr_type ON completion_requirements (type);`
);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS completion_requirements;`);
},
};
@@ -0,0 +1,38 @@
'use strict';
// Raw SQL for the same CockroachDB DO-block/CREATE TYPE reason as
// 20270101000067-create-completion-requirements.js.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE TABLE completion_requirement_progress (
progress_id UUID PRIMARY KEY,
requirement_id UUID NOT NULL REFERENCES completion_requirements (requirement_id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
entity_type STRING NOT NULL,
entity_id BIGINT NOT NULL,
progress_percent INTEGER NULL,
completed BOOLEAN NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ NULL,
block_progress JSONB NULL,
"createdBy" BIGINT NULL,
"updatedBy" BIGINT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT check_crp2_entity_type CHECK (entity_type IN ('course', 'unit', 'lesson')),
CONSTRAINT uq_crp2_requirement_user UNIQUE (requirement_id, user_id)
);
`);
await queryInterface.sequelize.query(
`CREATE INDEX idx_crp2_user_id ON completion_requirement_progress (user_id);`
);
await queryInterface.sequelize.query(
`CREATE INDEX idx_crp2_entity_type_entity_id ON completion_requirement_progress (entity_type, entity_id);`
);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS completion_requirement_progress;`);
},
};
@@ -0,0 +1,34 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('certificates', {
certificate_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, allowNull: false, unique: true, defaultValue: Sequelize.literal('gen_random_uuid()') },
cert_no: { type: Sequelize.STRING(25), allowNull: false, unique: true },
ref_no: { type: Sequelize.STRING(50), allowNull: false },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
instructors: { type: Sequelize.TEXT, allowNull: true },
score: { type: Sequelize.BIGINT, allowNull: true },
length_str: { type: Sequelize.STRING(50), allowNull: true },
issued_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
});
await queryInterface.addConstraint('certificates', {
fields: ['user_id', 'course_id'],
type: 'unique',
name: 'certificates_user_id_course_id_key',
});
await queryInterface.addIndex('certificates', ['course_id'], { name: 'idx_certificates_course_id' });
await queryInterface.addIndex('certificates', ['user_id'], { name: 'idx_certificates_user_id' });
await queryInterface.addIndex('certificates', ['uuid'], { name: 'idx_certificates_uuid' });
},
async down(queryInterface) {
await queryInterface.dropTable('certificates');
},
};
@@ -0,0 +1,37 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_instructors', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
order_index: { type: Sequelize.BIGINT, allowNull: false, defaultValue: 0 },
display_name: { type: Sequelize.STRING(255), allowNull: false, defaultValue: '' },
created_by: { type: Sequelize.BIGINT, allowNull: true },
created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
});
await queryInterface.addConstraint('course_instructors', {
fields: ['course_id', 'user_id'],
type: 'unique',
name: 'course_instructors_course_id_user_id_key',
});
await queryInterface.addIndex('course_instructors', ['course_id'], { name: 'idx_course_instructors_course' });
// Partial unique index — same columns as the constraint above, scoped to
// non-null user_id. Raw SQL since queryInterface addIndex's `where`
// support is unreliable for IS NOT NULL across dialects.
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX idx_course_instructors_user
ON course_instructors (course_id, user_id)
WHERE user_id IS NOT NULL;
`);
},
async down(queryInterface) {
await queryInterface.dropTable('course_instructors');
},
};
@@ -0,0 +1,31 @@
'use strict';
// started_at/last_saved_at/createdAt/updatedAt are TIMESTAMP WITHOUT TIME
// ZONE live (unlike most other tables' TIMESTAMPTZ columns), so raw type
// strings are used instead of Sequelize.DATE to match exactly.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_sessions', {
session_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, allowNull: false, unique: true, defaultValue: Sequelize.literal('gen_random_uuid()') },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
quiz_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'unit_quizzes', key: 'quiz_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: true },
unit_id: { type: Sequelize.BIGINT, allowNull: true },
status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'in_progress' },
draft_answers: { type: Sequelize.JSONB, allowNull: true },
started_at: { type: 'TIMESTAMP', allowNull: false, defaultValue: Sequelize.NOW },
last_saved_at: { type: 'TIMESTAMP', allowNull: true },
createdAt: { type: 'TIMESTAMP', allowNull: false, defaultValue: Sequelize.NOW },
updatedAt: { type: 'TIMESTAMP', allowNull: false, defaultValue: Sequelize.NOW },
});
await queryInterface.addIndex('quiz_sessions', ['quiz_id'], { name: 'idx_qs_quiz_id' });
await queryInterface.addIndex('quiz_sessions', ['status'], { name: 'idx_qs_status' });
await queryInterface.addIndex('quiz_sessions', ['user_id'], { name: 'idx_qs_user_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('quiz_sessions');
},
};
@@ -0,0 +1,41 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_bans', {
ban_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
banned_by: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' } },
reason: { type: Sequelize.TEXT, allowNull: false },
ban_type: { type: Sequelize.STRING(20), allowNull: false },
banned_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
expires_at: { type: Sequelize.DATE, allowNull: true },
is_lifted: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
lifted_at: { type: Sequelize.DATE, allowNull: true },
lifted_by: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'users', key: 'user_id' } },
lift_reason: { type: Sequelize.TEXT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
});
await queryInterface.addConstraint('user_bans', {
fields: ['ban_type'],
type: 'check',
name: 'check_ban_type',
where: { ban_type: { [Sequelize.Op.in]: ['temporary', 'permanent'] } },
});
await queryInterface.addIndex('user_bans', ['user_id'], { name: 'idx_user_bans_user_id' });
// Partial index — only rows relevant to the ban-expiry cron sweep.
await queryInterface.sequelize.query(`
CREATE INDEX idx_user_bans_expires_at
ON user_bans (expires_at)
WHERE is_lifted = false AND ban_type = 'temporary';
`);
},
async down(queryInterface) {
await queryInterface.dropTable('user_bans');
},
};
@@ -0,0 +1,32 @@
'use strict';
// Raw SQL for the same CockroachDB DO-block/CREATE TYPE reason as
// 20270101000067-create-completion-requirements.js.
//
// Decoupled from completion_requirements entirely — tracks last-known
// playback position for ANY video/audio block, regardless of whether the
// lesson has a watch-type completion requirement configured.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE TABLE media_playback_positions (
position_id UUID PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
lesson_id BIGINT NOT NULL REFERENCES lessons (lesson_id) ON DELETE CASCADE,
block_id STRING NOT NULL,
percent INTEGER NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_mpp_user_lesson_block UNIQUE (user_id, lesson_id, block_id)
);
`);
await queryInterface.sequelize.query(
`CREATE INDEX idx_mpp_user_lesson ON media_playback_positions (user_id, lesson_id);`
);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS media_playback_positions;`);
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_achievements', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false },
achievement_key: { type: Sequelize.STRING(100), allowNull: false },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('course_achievements', ['course_id']);
await queryInterface.addConstraint('course_achievements', {
fields: ['course_id', 'achievement_key'],
type: 'unique',
name: 'course_achievements_course_id_key_unique',
});
},
async down(queryInterface) {
await queryInterface.dropTable('course_achievements');
},
};
@@ -0,0 +1,24 @@
'use strict';
// Backstop for the stacked-tier model: application code now enforces "at most
// one active row per (user_id, tier)" (see controllers/client/tiers.controller.js
// createOrder/captureOrder and controllers/admin/tiers.controller.js grantTier),
// but a race or a future code path could still violate it. This partial unique
// index makes the DB reject that case outright instead of silently allowing two
// active rows for the same user+tier.
//
// NOT auto-run — this file only defines the migration. Do not execute it against
// the shared dev/prod database without explicit confirmation.
module.exports = {
async up(queryInterface) {
await queryInterface.addIndex('user_tiers', ['user_id', 'tier'], {
unique: true,
where: { status: 'active' },
name: 'user_tiers_one_active_per_user_tier',
});
},
async down(queryInterface) {
await queryInterface.removeIndex('user_tiers', 'user_tiers_one_active_per_user_tier');
},
};
@@ -0,0 +1,43 @@
'use strict';
// Tracks background container-remux jobs for video assets uploaded as .mov/
// .mkv — those formats often load slowly in-browser (moov/Cues index at the
// end of the file) compared to faststart .mp4. See services/ffmpeg.service.js
// + services/assetTranscode.service.js.
//
// STRING + explicit CHECK instead of a real ENUM — same CockroachDB
// constraint noted in 20270101000032-create-advertisements.js (addColumn
// can't create a new enum type the way createTable can).
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('assets', 'transcode_status', {
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: 'none',
});
await queryInterface.addColumn('assets', 'transcode_error', {
type: Sequelize.TEXT,
allowNull: true,
});
await queryInterface.addConstraint('assets', {
fields: ['transcode_status'],
type: 'check',
name: 'check_assets_transcode_status',
where: { transcode_status: { [Sequelize.Op.in]: ['none', 'pending', 'processing', 'done', 'failed'] } },
});
await queryInterface.sequelize.query(`
CREATE INDEX idx_assets_transcode_status
ON assets (transcode_status)
WHERE transcode_status IN ('pending', 'processing');
`);
},
async down(queryInterface) {
await queryInterface.removeConstraint('assets', 'check_assets_transcode_status');
await queryInterface.removeColumn('assets', 'transcode_status');
await queryInterface.removeColumn('assets', 'transcode_error');
},
};
@@ -0,0 +1,18 @@
'use strict';
// Standalone tier gate for Lessons, mirroring units.subscription (see
// 20270101000012-create-units.js) — null means open (or gated only via an
// attached unit/course). Full parity with the Unit-level gate added for the
// standalone Units/Lessons tier-gating extension.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('lessons', 'subscription', {
type: Sequelize.STRING(50),
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('lessons', 'subscription');
},
};
@@ -0,0 +1,81 @@
'use strict';
// Generalizes `products` from Course-only (`course_id`) to a polymorphic
// target (`purchasable_type` + `purchasable_id`) so standalone Units and
// Lessons can also be sold individually, alongside plan-tier gating — see
// utils/purchasable.util.js for the resolver used by every consumer.
//
// purchasable_type is STRING + explicit CHECK, not a real ENUM (CockroachDB
// can't create a new enum type via addColumn the way createTable can — same
// caveat as 20270101000076-add-assets-transcode-status.js).
//
// Dropping `course_id` also drops its FK (`onDelete: 'CASCADE'` from
// 20270101000019-create-products.js) — referential integrity across the three
// possible targets is now enforced at the application layer only, same as
// course_purchases.product_id already is today.
//
// Touches a table with live production rows (real courses have real
// products/course_purchases). NOT auto-run — test against a local/staging
// copy of the DB first; do not execute against the shared dev/prod database
// without explicit confirmation.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('products', 'purchasable_type', {
type: Sequelize.STRING(10),
allowNull: false,
defaultValue: 'course',
});
await queryInterface.addConstraint('products', {
fields: ['purchasable_type'],
type: 'check',
name: 'check_products_purchasable_type',
where: { purchasable_type: { [Sequelize.Op.in]: ['course', 'unit', 'lesson'] } },
});
await queryInterface.addColumn('products', 'purchasable_id', {
type: Sequelize.BIGINT,
allowNull: true, // filled by the backfill below, then locked to NOT NULL
});
await queryInterface.sequelize.query(`
UPDATE products SET purchasable_type = 'course', purchasable_id = course_id;
`);
await queryInterface.changeColumn('products', 'purchasable_id', {
type: Sequelize.BIGINT,
allowNull: false,
});
await queryInterface.removeIndex('products', ['course_id']);
await queryInterface.removeColumn('products', 'course_id');
await queryInterface.addIndex('products', ['purchasable_type', 'purchasable_id'], {
name: 'products_purchasable_type_id',
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex('products', 'products_purchasable_type_id');
await queryInterface.addColumn('products', 'course_id', {
type: Sequelize.BIGINT,
allowNull: true,
});
await queryInterface.sequelize.query(`
UPDATE products SET course_id = purchasable_id WHERE purchasable_type = 'course';
`);
await queryInterface.changeColumn('products', 'course_id', {
type: Sequelize.BIGINT,
allowNull: false,
});
await queryInterface.addIndex('products', ['course_id']);
await queryInterface.removeColumn('products', 'purchasable_id');
await queryInterface.removeConstraint('products', 'check_products_purchasable_type');
await queryInterface.removeColumn('products', 'purchasable_type');
},
};
@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('plan_units', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'tier_plans', key: 'plan_id' }, onDelete: 'CASCADE' },
unit_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('plan_units', ['plan_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('plan_units');
},
};
@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('plan_lessons', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'tier_plans', key: 'plan_id' }, onDelete: 'CASCADE' },
lesson_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('plan_lessons', ['plan_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('plan_lessons');
},
};
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_plans', 'createdBy', { type: Sequelize.BIGINT, allowNull: true });
await queryInterface.addColumn('tier_plans', 'updatedBy', { type: Sequelize.BIGINT, allowNull: true });
await queryInterface.addColumn('tier_plans', 'deletedBy', { type: Sequelize.BIGINT, allowNull: true });
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_plans', 'createdBy');
await queryInterface.removeColumn('tier_plans', 'updatedBy');
await queryInterface.removeColumn('tier_plans', 'deletedBy');
},
};
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_categories', 'is_special', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_categories', 'is_special');
},
};
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('categories', 'createdBy', { type: Sequelize.BIGINT, allowNull: true });
await queryInterface.addColumn('categories', 'updatedBy', { type: Sequelize.BIGINT, allowNull: true });
await queryInterface.addColumn('categories', 'deletedBy', { type: Sequelize.BIGINT, allowNull: true });
},
async down(queryInterface) {
await queryInterface.removeColumn('categories', 'createdBy');
await queryInterface.removeColumn('categories', 'updatedBy');
await queryInterface.removeColumn('categories', 'deletedBy');
},
};
@@ -0,0 +1,27 @@
'use strict';
// Per-alert layout image, replacing the old shared sticky_banner_settings
// singleton (see 20270101000066-create-sticky-banner-settings.js) — each
// notification_broadcasts row now carries its own optional image, copied
// onto admin_notifications/user_notifications at send time the same way
// color/show_in_sticky already are.
const TABLES = ['notification_broadcasts', 'admin_notifications', 'user_notifications'];
module.exports = {
async up(queryInterface, Sequelize) {
for (const table of TABLES) {
await queryInterface.addColumn(table, 'image_asset_id', {
type: Sequelize.BIGINT,
allowNull: true,
references: { model: 'assets', key: 'asset_id' },
onDelete: 'SET NULL',
});
}
},
async down(queryInterface) {
for (const table of TABLES) {
await queryInterface.removeColumn(table, 'image_asset_id');
}
},
};
@@ -0,0 +1,58 @@
'use strict';
// badge_label was a single STRING(100). The Type step redesign lets admins
// attach up to 2 badges (outline chips) to a "Text with Image" ad, so the
// column becomes a JSONB string array (renamed badge_labels to match the
// plural shape, mirroring the existing `ctas` JSONB column). Any existing
// scalar value is wrapped into a single-element array; null/empty becomes [].
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE advertisements
ALTER COLUMN badge_label TYPE JSONB
USING (
CASE
WHEN badge_label IS NULL OR badge_label = '' THEN '[]'::jsonb
ELSE jsonb_build_array(badge_label)
END
)
`);
await queryInterface.sequelize.query(`
ALTER TABLE advertisements
ALTER COLUMN badge_label SET DEFAULT '[]'::jsonb
`);
await queryInterface.sequelize.query(`
ALTER TABLE advertisements
ALTER COLUMN badge_label SET NOT NULL
`);
await queryInterface.renameColumn('advertisements', 'badge_label', 'badge_labels');
},
async down(queryInterface) {
await queryInterface.renameColumn('advertisements', 'badge_labels', 'badge_label');
await queryInterface.sequelize.query(`
ALTER TABLE advertisements
ALTER COLUMN badge_label DROP NOT NULL
`);
await queryInterface.sequelize.query(`
ALTER TABLE advertisements
ALTER COLUMN badge_label TYPE STRING(100)
USING (
CASE
WHEN jsonb_array_length(badge_label) = 0 THEN NULL
ELSE badge_label->>0
END
)
`);
await queryInterface.sequelize.query(`
ALTER TABLE advertisements
ALTER COLUMN badge_label DROP DEFAULT
`);
},
};
@@ -0,0 +1,18 @@
'use strict';
// Alerts are title-only now — the composer no longer collects a body message
// (see AddNotificationBroadcast.jsx). Only this table's column goes; the
// per-recipient admin_notifications/user_notifications.message columns stay
// (shared by ~20 unrelated notification types via NOTIFICATION_REGISTRY).
module.exports = {
async up(queryInterface) {
await queryInterface.removeColumn('notification_broadcasts', 'message');
},
async down(queryInterface, Sequelize) {
await queryInterface.addColumn('notification_broadcasts', 'message', {
type: Sequelize.TEXT,
allowNull: true,
});
},
};
@@ -0,0 +1,17 @@
'use strict';
// Notifications-type alerts need a body again; Sticky-type alerts stay
// title-only. Nullable this time (sticky rows never populate it) — reverses
// 20270101000086, which dropped this same column as NOT NULL.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('notification_broadcasts', 'message', {
type: Sequelize.TEXT,
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('notification_broadcasts', 'message');
},
};
@@ -0,0 +1,42 @@
'use strict';
// Tier Plans v2: bundle items (courses/units/lessons) may now belong to more
// than one plan simultaneously — the old single-column unique constraints
// enforced "one item = at most one plan ever", which blocks that. Replace
// each with a composite unique on (plan_id, item_id), which still prevents
// the same item being added twice to the *same* plan.
module.exports = {
async up(queryInterface) {
await queryInterface.removeConstraint('plan_courses', 'plan_courses_course_id_key').catch(() => {});
await queryInterface.removeIndex('plan_courses', 'plan_courses_course_id_key').catch(() => {});
await queryInterface.addIndex('plan_courses', ['plan_id', 'course_id'], {
unique: true,
name: 'plan_courses_plan_id_course_id_unique',
});
await queryInterface.removeConstraint('plan_units', 'plan_units_unit_id_key').catch(() => {});
await queryInterface.removeIndex('plan_units', 'plan_units_unit_id_key').catch(() => {});
await queryInterface.addIndex('plan_units', ['plan_id', 'unit_id'], {
unique: true,
name: 'plan_units_plan_id_unit_id_unique',
});
await queryInterface.removeConstraint('plan_lessons', 'plan_lessons_lesson_id_key').catch(() => {});
await queryInterface.removeIndex('plan_lessons', 'plan_lessons_lesson_id_key').catch(() => {});
await queryInterface.addIndex('plan_lessons', ['plan_id', 'lesson_id'], {
unique: true,
name: 'plan_lessons_plan_id_lesson_id_unique',
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex('plan_courses', 'plan_courses_plan_id_course_id_unique');
await queryInterface.changeColumn('plan_courses', 'course_id', { type: Sequelize.BIGINT, allowNull: false, unique: true });
await queryInterface.removeIndex('plan_units', 'plan_units_plan_id_unit_id_unique');
await queryInterface.changeColumn('plan_units', 'unit_id', { type: Sequelize.BIGINT, allowNull: false, unique: true });
await queryInterface.removeIndex('plan_lessons', 'plan_lessons_plan_id_lesson_id_unique');
await queryInterface.changeColumn('plan_lessons', 'lesson_id', { type: Sequelize.BIGINT, allowNull: false, unique: true });
},
};
@@ -0,0 +1,48 @@
'use strict';
// Tier Plans v2: item-specific entitlement. Until now a Tier Plan purchase
// only recorded `user_tiers.plan_id` + tier rank; the actual courses/units/
// lessons granted were re-resolved LIVE off plan_courses/plan_units/
// plan_lessons on every access check, so editing a plan's bundle later
// silently changed what past purchasers could access, and a Unit-only bundle
// couldn't be distinguished from its parent Course's bundle.
//
// This table is the purchase-time snapshot: one row per exact item granted
// by a given user_tiers purchase. Access checks now look up rows here
// instead of comparing tier rank against item subscription (see hasItemGrant()
// in controllers/client/courses.controller.js).
//
// item_type is STRING + CHECK, not a real ENUM, mirroring
// products.purchasable_type (20270101000078) — CockroachDB can't create a
// new enum type via addColumn the way createTable can.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_tier_grants', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_tier_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'user_tiers', key: 'tier_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
plan_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'tier_plans', key: 'plan_id' }, onUpdate: 'CASCADE', onDelete: 'SET NULL' },
item_type: { type: Sequelize.STRING(10), allowNull: false },
item_id: { type: Sequelize.BIGINT, allowNull: false },
granted_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addConstraint('user_tier_grants', {
fields: ['item_type'],
type: 'check',
name: 'check_user_tier_grants_item_type',
where: { item_type: { [Sequelize.Op.in]: ['course', 'unit', 'lesson'] } },
});
await queryInterface.addIndex('user_tier_grants', ['user_id', 'item_type', 'item_id'], {
name: 'user_tier_grants_user_item_idx',
});
await queryInterface.addIndex('user_tier_grants', ['user_tier_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('user_tier_grants');
},
};
@@ -0,0 +1,38 @@
'use strict';
// Tier Plans v2: item-specific entitlement means two DIFFERENT plans at the
// SAME tier slug (e.g. two separate Premium bundles) must be able to stay
// concurrently active for one user, each governing its own distinct
// user_tier_grants snapshot — buying a second Premium bundle must not just
// extend the first one's expiry (see captureOrder in
// controllers/client/tiers.controller.js).
//
// The old partial unique index (20270101000075) enforced "one active row per
// (user_id, tier)", which blocks that. Replace it with "one active row per
// (user_id, plan_id)" — still prevents accidentally double-purchasing/
// double-granting the exact same plan concurrently, but allows multiple
// plans at the same tier to coexist. NULL plan_id rows (admin-granted
// tiers with no backing plan) are never considered equal to one another by
// a unique index, so they're unaffected.
//
// NOT auto-run — do not execute against the shared dev/prod database without
// explicit confirmation.
module.exports = {
async up(queryInterface) {
await queryInterface.removeIndex('user_tiers', 'user_tiers_one_active_per_user_tier');
await queryInterface.addIndex('user_tiers', ['user_id', 'plan_id'], {
unique: true,
where: { status: 'active' },
name: 'user_tiers_one_active_per_user_plan',
});
},
async down(queryInterface) {
await queryInterface.removeIndex('user_tiers', 'user_tiers_one_active_per_user_plan');
await queryInterface.addIndex('user_tiers', ['user_id', 'tier'], {
unique: true,
where: { status: 'active' },
name: 'user_tiers_one_active_per_user_tier',
});
},
};
@@ -0,0 +1,31 @@
'use strict';
// The "Access Rules" feature (rule-based access on top of/instead of item-
// specific entitlement — course_subscription_access/required_active_tier/
// group_restriction/item_allowlist rule types, plus the "starter-set purchase
// eligibility" gate derived from item_allowlist) has been removed entirely,
// admin UI and runtime enforcement both — item-specific `user_tier_grants` is
// the only entitlement mechanism now (see hasItemGrant() in
// controllers/client/courses.controller.js). No other table has an FK into
// plan_policies (only plan_policies.plan_id -> tier_plans.plan_id), so this
// is a clean, isolated drop.
//
// NOT auto-run — do not execute against the shared dev/prod database without
// explicit confirmation.
module.exports = {
async up(queryInterface) {
await queryInterface.dropTable('plan_policies');
},
async down(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 },
});
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_plans', 'status', {
type: Sequelize.ENUM('draft', 'published'),
allowNull: false,
defaultValue: 'draft',
});
// Preserve current live behavior — every plan that already exists is
// visible on the client today (only is_active governs purchasability),
// so backfill all of them to 'published'. The 'draft' default only
// takes effect for plans created after this migration.
await queryInterface.sequelize.query(`UPDATE "tier_plans" SET "status" = 'published'`);
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_plans', 'status');
},
};
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_plans', 'is_recommended', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_plans', 'is_recommended');
},
};