add changes
This commit is contained in:
22
migrations/20251109214244-create-users.js
Normal file
22
migrations/20251109214244-create-users.js
Normal file
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
/**
|
||||
* Add altering commands here.
|
||||
*
|
||||
* Example:
|
||||
* await queryInterface.createTable('users', { id: Sequelize.INTEGER });
|
||||
*/
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
/**
|
||||
* Add reverting commands here.
|
||||
*
|
||||
* Example:
|
||||
* await queryInterface.dropTable('users');
|
||||
*/
|
||||
}
|
||||
};
|
||||
143
migrations/20251109214253-create-users.js
Normal file
143
migrations/20251109214253-create-users.js
Normal file
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('users', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
username: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'Unique username'
|
||||
},
|
||||
email: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'User email address'
|
||||
},
|
||||
password: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
comment: 'Hashed password'
|
||||
},
|
||||
role: {
|
||||
type: Sequelize.ENUM('admin', 'user'),
|
||||
allowNull: false,
|
||||
defaultValue: 'user',
|
||||
comment: 'User role'
|
||||
},
|
||||
profile_image: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'Profile image URL'
|
||||
},
|
||||
is_active: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
comment: 'Account active status'
|
||||
},
|
||||
|
||||
// Statistics
|
||||
total_quizzes: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Total number of quizzes taken'
|
||||
},
|
||||
quizzes_passed: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of quizzes passed'
|
||||
},
|
||||
total_questions_answered: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Total questions answered'
|
||||
},
|
||||
correct_answers: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of correct answers'
|
||||
},
|
||||
current_streak: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Current daily streak'
|
||||
},
|
||||
longest_streak: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Longest daily streak achieved'
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
last_login: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: 'Last login timestamp'
|
||||
},
|
||||
last_quiz_date: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: 'Date of last quiz taken'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('users', ['email'], {
|
||||
name: 'idx_users_email',
|
||||
unique: true
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('users', ['username'], {
|
||||
name: 'idx_users_username',
|
||||
unique: true
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('users', ['role'], {
|
||||
name: 'idx_users_role'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('users', ['is_active'], {
|
||||
name: 'idx_users_is_active'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('users', ['created_at'], {
|
||||
name: 'idx_users_created_at'
|
||||
});
|
||||
|
||||
console.log('✅ Users table created successfully with indexes');
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('users');
|
||||
console.log('✅ Users table dropped successfully');
|
||||
}
|
||||
};
|
||||
126
migrations/20251109214935-create-categories.js
Normal file
126
migrations/20251109214935-create-categories.js
Normal file
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('categories', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'Category name'
|
||||
},
|
||||
slug: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'URL-friendly slug'
|
||||
},
|
||||
description: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Category description'
|
||||
},
|
||||
icon: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'Icon URL or class'
|
||||
},
|
||||
color: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
comment: 'Display color (hex or name)'
|
||||
},
|
||||
is_active: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
comment: 'Category active status'
|
||||
},
|
||||
guest_accessible: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment: 'Whether guests can access this category'
|
||||
},
|
||||
|
||||
// Statistics
|
||||
question_count: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Total number of questions in this category'
|
||||
},
|
||||
quiz_count: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Total number of quizzes taken in this category'
|
||||
},
|
||||
|
||||
// Display order
|
||||
display_order: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Display order (lower numbers first)'
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('categories', ['name'], {
|
||||
unique: true,
|
||||
name: 'idx_categories_name'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('categories', ['slug'], {
|
||||
unique: true,
|
||||
name: 'idx_categories_slug'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('categories', ['is_active'], {
|
||||
name: 'idx_categories_is_active'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('categories', ['guest_accessible'], {
|
||||
name: 'idx_categories_guest_accessible'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('categories', ['display_order'], {
|
||||
name: 'idx_categories_display_order'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('categories', ['is_active', 'guest_accessible'], {
|
||||
name: 'idx_categories_active_guest'
|
||||
});
|
||||
|
||||
console.log('✅ Categories table created successfully with indexes');
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('categories');
|
||||
console.log('✅ Categories table dropped successfully');
|
||||
}
|
||||
};
|
||||
191
migrations/20251109220030-create-questions.js
Normal file
191
migrations/20251109220030-create-questions.js
Normal file
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
console.log('Creating questions table...');
|
||||
|
||||
await queryInterface.createTable('questions', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
category_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'categories',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'RESTRICT',
|
||||
comment: 'Foreign key to categories table'
|
||||
},
|
||||
created_by: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
comment: 'User who created the question (admin)'
|
||||
},
|
||||
question_text: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
comment: 'The question text'
|
||||
},
|
||||
question_type: {
|
||||
type: Sequelize.ENUM('multiple', 'trueFalse', 'written'),
|
||||
allowNull: false,
|
||||
defaultValue: 'multiple',
|
||||
comment: 'Type of question'
|
||||
},
|
||||
options: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Answer options for multiple choice (JSON array)'
|
||||
},
|
||||
correct_answer: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
comment: 'Correct answer (index for multiple choice, true/false for boolean)'
|
||||
},
|
||||
explanation: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Explanation for the correct answer'
|
||||
},
|
||||
difficulty: {
|
||||
type: Sequelize.ENUM('easy', 'medium', 'hard'),
|
||||
allowNull: false,
|
||||
defaultValue: 'medium',
|
||||
comment: 'Question difficulty level'
|
||||
},
|
||||
points: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 10,
|
||||
comment: 'Points awarded for correct answer'
|
||||
},
|
||||
time_limit: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Time limit in seconds (optional)'
|
||||
},
|
||||
keywords: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Search keywords (JSON array)'
|
||||
},
|
||||
tags: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Tags for categorization (JSON array)'
|
||||
},
|
||||
visibility: {
|
||||
type: Sequelize.ENUM('public', 'registered', 'premium'),
|
||||
allowNull: false,
|
||||
defaultValue: 'registered',
|
||||
comment: 'Who can see this question'
|
||||
},
|
||||
guest_accessible: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment: 'Whether guests can access this question'
|
||||
},
|
||||
is_active: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
comment: 'Question active status'
|
||||
},
|
||||
times_attempted: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of times question was attempted'
|
||||
},
|
||||
times_correct: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of times answered correctly'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
engine: 'InnoDB'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('questions', ['category_id'], {
|
||||
name: 'idx_questions_category_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('questions', ['created_by'], {
|
||||
name: 'idx_questions_created_by'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('questions', ['question_type'], {
|
||||
name: 'idx_questions_question_type'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('questions', ['difficulty'], {
|
||||
name: 'idx_questions_difficulty'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('questions', ['visibility'], {
|
||||
name: 'idx_questions_visibility'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('questions', ['guest_accessible'], {
|
||||
name: 'idx_questions_guest_accessible'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('questions', ['is_active'], {
|
||||
name: 'idx_questions_is_active'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('questions', ['created_at'], {
|
||||
name: 'idx_questions_created_at'
|
||||
});
|
||||
|
||||
// Composite index for common query patterns
|
||||
await queryInterface.addIndex('questions', ['category_id', 'is_active', 'difficulty'], {
|
||||
name: 'idx_questions_category_active_difficulty'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('questions', ['is_active', 'guest_accessible'], {
|
||||
name: 'idx_questions_active_guest'
|
||||
});
|
||||
|
||||
// Full-text search index
|
||||
await queryInterface.sequelize.query(
|
||||
'CREATE FULLTEXT INDEX idx_questions_fulltext ON questions(question_text, explanation)'
|
||||
);
|
||||
|
||||
console.log('✅ Questions table created successfully with indexes and full-text search');
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
console.log('Dropping questions table...');
|
||||
await queryInterface.dropTable('questions');
|
||||
console.log('✅ Questions table dropped successfully');
|
||||
}
|
||||
};
|
||||
131
migrations/20251109221034-create-guest-sessions.js
Normal file
131
migrations/20251109221034-create-guest-sessions.js
Normal file
@@ -0,0 +1,131 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
console.log('Creating guest_sessions table...');
|
||||
|
||||
await queryInterface.createTable('guest_sessions', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
guest_id: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'Unique guest identifier'
|
||||
},
|
||||
session_token: {
|
||||
type: Sequelize.STRING(500),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'JWT session token'
|
||||
},
|
||||
device_id: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'Device identifier (optional)'
|
||||
},
|
||||
ip_address: {
|
||||
type: Sequelize.STRING(45),
|
||||
allowNull: true,
|
||||
comment: 'IP address (supports IPv6)'
|
||||
},
|
||||
user_agent: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Browser user agent string'
|
||||
},
|
||||
quizzes_attempted: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of quizzes attempted by guest'
|
||||
},
|
||||
max_quizzes: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 3,
|
||||
comment: 'Maximum quizzes allowed for this guest'
|
||||
},
|
||||
expires_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
comment: 'Session expiration timestamp'
|
||||
},
|
||||
is_converted: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment: 'Whether guest converted to registered user'
|
||||
},
|
||||
converted_user_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
comment: 'User ID if guest converted to registered user'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
engine: 'InnoDB'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('guest_sessions', ['guest_id'], {
|
||||
unique: true,
|
||||
name: 'idx_guest_sessions_guest_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('guest_sessions', ['session_token'], {
|
||||
unique: true,
|
||||
name: 'idx_guest_sessions_session_token'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('guest_sessions', ['expires_at'], {
|
||||
name: 'idx_guest_sessions_expires_at'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('guest_sessions', ['is_converted'], {
|
||||
name: 'idx_guest_sessions_is_converted'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('guest_sessions', ['converted_user_id'], {
|
||||
name: 'idx_guest_sessions_converted_user_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('guest_sessions', ['device_id'], {
|
||||
name: 'idx_guest_sessions_device_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('guest_sessions', ['created_at'], {
|
||||
name: 'idx_guest_sessions_created_at'
|
||||
});
|
||||
|
||||
console.log('✅ Guest sessions table created successfully with indexes');
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
console.log('Dropping guest_sessions table...');
|
||||
await queryInterface.dropTable('guest_sessions');
|
||||
console.log('✅ Guest sessions table dropped successfully');
|
||||
}
|
||||
};
|
||||
203
migrations/20251110190953-create-quiz-sessions.js
Normal file
203
migrations/20251110190953-create-quiz-sessions.js
Normal file
@@ -0,0 +1,203 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('quiz_sessions', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
comment: 'Foreign key to users table (null for guest quizzes)'
|
||||
},
|
||||
guest_session_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'guest_sessions',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
comment: 'Foreign key to guest_sessions table (null for user quizzes)'
|
||||
},
|
||||
category_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'categories',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'RESTRICT',
|
||||
comment: 'Foreign key to categories table'
|
||||
},
|
||||
quiz_type: {
|
||||
type: Sequelize.ENUM('practice', 'timed', 'exam'),
|
||||
allowNull: false,
|
||||
defaultValue: 'practice',
|
||||
comment: 'Type of quiz: practice (untimed), timed, or exam mode'
|
||||
},
|
||||
difficulty: {
|
||||
type: Sequelize.ENUM('easy', 'medium', 'hard', 'mixed'),
|
||||
allowNull: false,
|
||||
defaultValue: 'mixed',
|
||||
comment: 'Difficulty level of questions in the quiz'
|
||||
},
|
||||
total_questions: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 10,
|
||||
comment: 'Total number of questions in this quiz session'
|
||||
},
|
||||
questions_answered: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of questions answered so far'
|
||||
},
|
||||
correct_answers: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of correct answers'
|
||||
},
|
||||
score: {
|
||||
type: Sequelize.DECIMAL(5, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0.00,
|
||||
comment: 'Quiz score as percentage (0-100)'
|
||||
},
|
||||
total_points: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Total points earned in this quiz'
|
||||
},
|
||||
max_points: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Maximum possible points for this quiz'
|
||||
},
|
||||
time_limit: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: true,
|
||||
comment: 'Time limit in seconds (null for untimed practice)'
|
||||
},
|
||||
time_spent: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Total time spent in seconds'
|
||||
},
|
||||
started_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When the quiz was started'
|
||||
},
|
||||
completed_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When the quiz was completed'
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.ENUM('not_started', 'in_progress', 'completed', 'abandoned', 'timed_out'),
|
||||
allowNull: false,
|
||||
defaultValue: 'not_started',
|
||||
comment: 'Current status of the quiz session'
|
||||
},
|
||||
is_passed: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: true,
|
||||
comment: 'Whether the quiz was passed (null if not completed)'
|
||||
},
|
||||
pass_percentage: {
|
||||
type: Sequelize.DECIMAL(5, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 70.00,
|
||||
comment: 'Required percentage to pass (default 70%)'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record creation timestamp'
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record last update timestamp'
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
comment: 'Tracks individual quiz sessions for users and guests'
|
||||
});
|
||||
|
||||
// Add indexes for better query performance
|
||||
await queryInterface.addIndex('quiz_sessions', ['user_id'], {
|
||||
name: 'idx_quiz_sessions_user_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['guest_session_id'], {
|
||||
name: 'idx_quiz_sessions_guest_session_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['category_id'], {
|
||||
name: 'idx_quiz_sessions_category_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['status'], {
|
||||
name: 'idx_quiz_sessions_status'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['quiz_type'], {
|
||||
name: 'idx_quiz_sessions_quiz_type'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['started_at'], {
|
||||
name: 'idx_quiz_sessions_started_at'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['completed_at'], {
|
||||
name: 'idx_quiz_sessions_completed_at'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['created_at'], {
|
||||
name: 'idx_quiz_sessions_created_at'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['is_passed'], {
|
||||
name: 'idx_quiz_sessions_is_passed'
|
||||
});
|
||||
|
||||
// Composite index for common queries
|
||||
await queryInterface.addIndex('quiz_sessions', ['user_id', 'status'], {
|
||||
name: 'idx_quiz_sessions_user_status'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['guest_session_id', 'status'], {
|
||||
name: 'idx_quiz_sessions_guest_status'
|
||||
});
|
||||
|
||||
console.log('✅ Quiz sessions table created successfully with 21 fields and 11 indexes');
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('quiz_sessions');
|
||||
console.log('✅ Quiz sessions table dropped');
|
||||
}
|
||||
};
|
||||
111
migrations/20251110191735-create-quiz-answers.js
Normal file
111
migrations/20251110191735-create-quiz-answers.js
Normal file
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('quiz_answers', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
quiz_session_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'quiz_sessions',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Foreign key to quiz_sessions table'
|
||||
},
|
||||
question_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'questions',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Foreign key to questions table'
|
||||
},
|
||||
selected_option: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
comment: 'The option selected by the user'
|
||||
},
|
||||
is_correct: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
comment: 'Whether the selected answer was correct'
|
||||
},
|
||||
points_earned: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Points earned for this answer'
|
||||
},
|
||||
time_taken: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Time taken to answer in seconds'
|
||||
},
|
||||
answered_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'When the question was answered'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record creation timestamp'
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record last update timestamp'
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
comment: 'Stores individual answers given during quiz sessions'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('quiz_answers', ['quiz_session_id'], {
|
||||
name: 'idx_quiz_answers_session_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_answers', ['question_id'], {
|
||||
name: 'idx_quiz_answers_question_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_answers', ['is_correct'], {
|
||||
name: 'idx_quiz_answers_is_correct'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_answers', ['answered_at'], {
|
||||
name: 'idx_quiz_answers_answered_at'
|
||||
});
|
||||
|
||||
// Composite index for session + question (unique constraint)
|
||||
await queryInterface.addIndex('quiz_answers', ['quiz_session_id', 'question_id'], {
|
||||
name: 'idx_quiz_answers_session_question',
|
||||
unique: true
|
||||
});
|
||||
|
||||
console.log('✅ Quiz answers table created successfully with 9 fields and 5 indexes');
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('quiz_answers');
|
||||
console.log('✅ Quiz answers table dropped');
|
||||
}
|
||||
};
|
||||
84
migrations/20251110191906-create-quiz-session-questions.js
Normal file
84
migrations/20251110191906-create-quiz-session-questions.js
Normal file
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('quiz_session_questions', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
quiz_session_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'quiz_sessions',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Foreign key to quiz_sessions table'
|
||||
},
|
||||
question_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'questions',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Foreign key to questions table'
|
||||
},
|
||||
question_order: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
comment: 'Order of question in the quiz (1-based)'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record creation timestamp'
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record last update timestamp'
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
comment: 'Junction table linking quiz sessions with questions'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id'], {
|
||||
name: 'idx_qsq_session_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_session_questions', ['question_id'], {
|
||||
name: 'idx_qsq_question_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_session_questions', ['question_order'], {
|
||||
name: 'idx_qsq_question_order'
|
||||
});
|
||||
|
||||
// Unique composite index to prevent duplicate questions in same session
|
||||
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id', 'question_id'], {
|
||||
name: 'idx_qsq_session_question',
|
||||
unique: true
|
||||
});
|
||||
|
||||
console.log('✅ Quiz session questions junction table created with 5 fields and 4 indexes');
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('quiz_session_questions');
|
||||
console.log('✅ Quiz session questions table dropped');
|
||||
}
|
||||
};
|
||||
84
migrations/20251110192000-create-user-bookmarks.js
Normal file
84
migrations/20251110192000-create-user-bookmarks.js
Normal file
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('user_bookmarks', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Foreign key to users table'
|
||||
},
|
||||
question_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'questions',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Foreign key to questions table'
|
||||
},
|
||||
notes: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Optional user notes about the bookmarked question'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'When the bookmark was created'
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record last update timestamp'
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
comment: 'Junction table for users bookmarking questions'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('user_bookmarks', ['user_id'], {
|
||||
name: 'idx_user_bookmarks_user_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('user_bookmarks', ['question_id'], {
|
||||
name: 'idx_user_bookmarks_question_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('user_bookmarks', ['created_at'], {
|
||||
name: 'idx_user_bookmarks_created_at'
|
||||
});
|
||||
|
||||
// Unique composite index to prevent duplicate bookmarks
|
||||
await queryInterface.addIndex('user_bookmarks', ['user_id', 'question_id'], {
|
||||
name: 'idx_user_bookmarks_user_question',
|
||||
unique: true
|
||||
});
|
||||
|
||||
console.log('✅ User bookmarks table created with 5 fields and 4 indexes');
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('user_bookmarks');
|
||||
console.log('✅ User bookmarks table dropped');
|
||||
}
|
||||
};
|
||||
122
migrations/20251110192043-create-achievements.js
Normal file
122
migrations/20251110192043-create-achievements.js
Normal file
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('achievements', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'Unique name of the achievement'
|
||||
},
|
||||
slug: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: 'URL-friendly slug'
|
||||
},
|
||||
description: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
comment: 'Description of the achievement'
|
||||
},
|
||||
icon: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: true,
|
||||
comment: 'Icon identifier (e.g., emoji or icon class)'
|
||||
},
|
||||
badge_color: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
defaultValue: '#FFD700',
|
||||
comment: 'Hex color code for the badge'
|
||||
},
|
||||
category: {
|
||||
type: Sequelize.ENUM('quiz', 'streak', 'score', 'speed', 'milestone', 'special'),
|
||||
allowNull: false,
|
||||
defaultValue: 'milestone',
|
||||
comment: 'Category of achievement'
|
||||
},
|
||||
requirement_type: {
|
||||
type: Sequelize.ENUM('quizzes_completed', 'quizzes_passed', 'perfect_score', 'streak_days', 'total_questions', 'category_master', 'speed_demon', 'early_bird'),
|
||||
allowNull: false,
|
||||
comment: 'Type of requirement to earn the achievement'
|
||||
},
|
||||
requirement_value: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
comment: 'Value needed to satisfy requirement (e.g., 10 for "10 quizzes")'
|
||||
},
|
||||
points: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 10,
|
||||
comment: 'Points awarded when achievement is earned'
|
||||
},
|
||||
is_active: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
comment: 'Whether this achievement is currently available'
|
||||
},
|
||||
display_order: {
|
||||
type: Sequelize.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Display order in achievement list'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record creation timestamp'
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record last update timestamp'
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
comment: 'Defines available achievements users can earn'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('achievements', ['slug'], {
|
||||
name: 'idx_achievements_slug',
|
||||
unique: true
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('achievements', ['category'], {
|
||||
name: 'idx_achievements_category'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('achievements', ['requirement_type'], {
|
||||
name: 'idx_achievements_requirement_type'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('achievements', ['is_active'], {
|
||||
name: 'idx_achievements_is_active'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('achievements', ['display_order'], {
|
||||
name: 'idx_achievements_display_order'
|
||||
});
|
||||
|
||||
console.log('✅ Achievements table created with 13 fields and 5 indexes');
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('achievements');
|
||||
console.log('✅ Achievements table dropped');
|
||||
}
|
||||
};
|
||||
95
migrations/20251110192130-create-user-achievements.js
Normal file
95
migrations/20251110192130-create-user-achievements.js
Normal file
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('user_achievements', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Foreign key to users table'
|
||||
},
|
||||
achievement_id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'achievements',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Foreign key to achievements table'
|
||||
},
|
||||
earned_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'When the achievement was earned'
|
||||
},
|
||||
notified: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment: 'Whether user has been notified about this achievement'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record creation timestamp'
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: 'Record last update timestamp'
|
||||
}
|
||||
}, {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
comment: 'Junction table tracking achievements earned by users'
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex('user_achievements', ['user_id'], {
|
||||
name: 'idx_user_achievements_user_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('user_achievements', ['achievement_id'], {
|
||||
name: 'idx_user_achievements_achievement_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('user_achievements', ['earned_at'], {
|
||||
name: 'idx_user_achievements_earned_at'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('user_achievements', ['notified'], {
|
||||
name: 'idx_user_achievements_notified'
|
||||
});
|
||||
|
||||
// Unique composite index to prevent duplicate achievements
|
||||
await queryInterface.addIndex('user_achievements', ['user_id', 'achievement_id'], {
|
||||
name: 'idx_user_achievements_user_achievement',
|
||||
unique: true
|
||||
});
|
||||
|
||||
console.log('✅ User achievements table created with 6 fields and 5 indexes');
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('user_achievements');
|
||||
console.log('✅ User achievements table dropped');
|
||||
}
|
||||
};
|
||||
105
migrations/20251112-add-performance-indexes.js
Normal file
105
migrations/20251112-add-performance-indexes.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Migration: Add Database Indexes for Performance Optimization
|
||||
*
|
||||
* This migration adds indexes to improve query performance for:
|
||||
* - QuizSession: userId, guestSessionId, categoryId, status, createdAt
|
||||
* - QuizSessionQuestion: quizSessionId, questionId
|
||||
*
|
||||
* Note: Other models (User, Question, Category, GuestSession, QuizAnswer, UserBookmark)
|
||||
* already have indexes defined in their models.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
console.log('Adding performance indexes...');
|
||||
|
||||
try {
|
||||
// QuizSession indexes
|
||||
await queryInterface.addIndex('quiz_sessions', ['user_id'], {
|
||||
name: 'idx_quiz_sessions_user_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['guest_session_id'], {
|
||||
name: 'idx_quiz_sessions_guest_session_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['category_id'], {
|
||||
name: 'idx_quiz_sessions_category_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['status'], {
|
||||
name: 'idx_quiz_sessions_status'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['created_at'], {
|
||||
name: 'idx_quiz_sessions_created_at'
|
||||
});
|
||||
|
||||
// Composite indexes for common queries
|
||||
await queryInterface.addIndex('quiz_sessions', ['user_id', 'created_at'], {
|
||||
name: 'idx_quiz_sessions_user_created'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['guest_session_id', 'created_at'], {
|
||||
name: 'idx_quiz_sessions_guest_created'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_sessions', ['category_id', 'status'], {
|
||||
name: 'idx_quiz_sessions_category_status'
|
||||
});
|
||||
|
||||
// QuizSessionQuestion indexes
|
||||
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id'], {
|
||||
name: 'idx_quiz_session_questions_session_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_session_questions', ['question_id'], {
|
||||
name: 'idx_quiz_session_questions_question_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id', 'question_order'], {
|
||||
name: 'idx_quiz_session_questions_session_order'
|
||||
});
|
||||
|
||||
// Unique constraint to prevent duplicate questions in same session
|
||||
await queryInterface.addIndex('quiz_session_questions', ['quiz_session_id', 'question_id'], {
|
||||
name: 'idx_quiz_session_questions_session_question_unique',
|
||||
unique: true
|
||||
});
|
||||
|
||||
console.log('✅ Performance indexes added successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error adding indexes:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
console.log('Removing performance indexes...');
|
||||
|
||||
try {
|
||||
// Remove QuizSession indexes
|
||||
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_user_id');
|
||||
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_guest_session_id');
|
||||
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_category_id');
|
||||
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_status');
|
||||
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_created_at');
|
||||
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_user_created');
|
||||
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_guest_created');
|
||||
await queryInterface.removeIndex('quiz_sessions', 'idx_quiz_sessions_category_status');
|
||||
|
||||
// Remove QuizSessionQuestion indexes
|
||||
await queryInterface.removeIndex('quiz_session_questions', 'idx_quiz_session_questions_session_id');
|
||||
await queryInterface.removeIndex('quiz_session_questions', 'idx_quiz_session_questions_question_id');
|
||||
await queryInterface.removeIndex('quiz_session_questions', 'idx_quiz_session_questions_session_order');
|
||||
await queryInterface.removeIndex('quiz_session_questions', 'idx_quiz_session_questions_session_question_unique');
|
||||
|
||||
console.log('✅ Performance indexes removed successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error removing indexes:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
61
migrations/20251112000000-create-guest-settings.js
Normal file
61
migrations/20251112000000-create-guest-settings.js
Normal file
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
console.log('Creating guest_settings table...');
|
||||
|
||||
await queryInterface.createTable('guest_settings', {
|
||||
id: {
|
||||
type: Sequelize.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
max_quizzes: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 3,
|
||||
comment: 'Maximum number of quizzes a guest can take'
|
||||
},
|
||||
expiry_hours: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 24,
|
||||
comment: 'Guest session expiry time in hours'
|
||||
},
|
||||
public_categories: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: '[]',
|
||||
comment: 'Array of category UUIDs accessible to guests'
|
||||
},
|
||||
feature_restrictions: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: '{"allowBookmarks":false,"allowReview":true,"allowPracticeMode":true,"allowTimedMode":false,"allowExamMode":false}',
|
||||
comment: 'Feature restrictions for guest users'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
|
||||
}
|
||||
}, {
|
||||
comment: 'System-wide guest user settings'
|
||||
});
|
||||
|
||||
console.log('✅ guest_settings table created successfully');
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
console.log('Dropping guest_settings table...');
|
||||
await queryInterface.dropTable('guest_settings');
|
||||
console.log('✅ guest_settings table dropped successfully');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user