course,tasklist,task and completed validation

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-17 13:04:27 +08:00
parent 612805acaf
commit d49e3be4d2
26 changed files with 1174 additions and 69 deletions
@@ -0,0 +1,21 @@
'use strict';
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_tp_task_id' });
await queryInterface.addIndex('task_prerequisites', { fields: ['prerequisite_task_id'], name: 'idx_tp_prerequisite_task_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_prerequisites');
},
};
@@ -0,0 +1,59 @@
'use strict';
// Reshapes completion_requirement_progress.block_progress from
// { [blockId]: percentNumber } to { [blockId]: { percent, updatedAt } } so
// recordWatchProgress can validate a reported percent against real elapsed
// wall-clock time since that block's last sample (anti-skip hardening —
// closes the "one API call reports 100%" gaming hole). Applies uniformly to
// watch_percent rows too, which start using block_progress as of this change
// (previously only watch_video/listen_audio populated it).
//
// Data-only migration — the column type itself (JSONB) doesn't change, so
// this is a plain JS loop + parameterized UPDATE rather than raw jsonb SQL,
// consistent with this table's CockroachDB-compatibility precedent (see
// 20260715000002, which avoids native ALTER TYPE/enum SQL for the same reason).
module.exports = {
async up(queryInterface) {
const rows = await queryInterface.sequelize.query(
`SELECT progress_id, block_progress, "updatedAt" FROM completion_requirement_progress WHERE block_progress IS NOT NULL`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
for (const row of rows) {
const bp = row.block_progress ?? {};
const alreadyShaped = Object.values(bp).every((v) => v && typeof v === 'object');
if (alreadyShaped) continue;
const reshaped = Object.fromEntries(
Object.entries(bp).map(([blockId, percent]) => [
blockId, { percent: Number(percent) || 0, updatedAt: row.updatedAt ?? new Date().toISOString() },
])
);
await queryInterface.sequelize.query(
`UPDATE completion_requirement_progress SET block_progress = :bp WHERE progress_id = :id`,
{ replacements: { bp: JSON.stringify(reshaped), id: row.progress_id } }
);
}
},
async down(queryInterface) {
const rows = await queryInterface.sequelize.query(
`SELECT progress_id, block_progress FROM completion_requirement_progress WHERE block_progress IS NOT NULL`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
for (const row of rows) {
const bp = row.block_progress ?? {};
const flattened = Object.fromEntries(
Object.entries(bp).map(([blockId, v]) => [blockId, v?.percent ?? v])
);
await queryInterface.sequelize.query(
`UPDATE completion_requirement_progress SET block_progress = :bp WHERE progress_id = :id`,
{ replacements: { bp: JSON.stringify(flattened), id: row.progress_id } }
);
}
},
};
@@ -0,0 +1,33 @@
'use strict';
// Raw SQL for the same CockroachDB DO-block/CREATE TYPE reason as
// 20260714000002-create-completion-requirement-progress.js.
//
// Decoupled from completion_requirements entirely — tracks last-known playback
// position for ANY video/audio block, regardless of whether the lesson has a
// watch-type completion requirement configured. Powers resume-on-reopen only.
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,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');
},
};