mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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');
|
||||
},
|
||||
};
|
||||
+24
@@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
+58
@@ -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
|
||||
`);
|
||||
},
|
||||
};
|
||||
+18
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
+17
@@ -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');
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user