units,lesson as standalone

This commit is contained in:
2026-07-10 11:44:46 +08:00
parent 86fba50b95
commit e1ffdab190
46 changed files with 1463 additions and 197 deletions
@@ -0,0 +1,57 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// ─── notification_broadcasts ────────────────────────────────────────────
await queryInterface.addColumn('notification_broadcasts', 'show_in_sticky', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
await queryInterface.addColumn('notification_broadcasts', 'show_in_notifications', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
// ─── admin_notifications (admin bell) ───────────────────────────────────
await queryInterface.addColumn('admin_notifications', 'show_in_notifications', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
// Not used by admin UI right now, but keeps filtering semantics symmetric.
await queryInterface.addColumn('admin_notifications', 'show_in_sticky', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
// ─── user_notifications (client notifications + sticky banner) ────────
await queryInterface.addColumn('user_notifications', 'show_in_notifications', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
await queryInterface.addColumn('user_notifications', 'show_in_sticky', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('user_notifications', 'show_in_sticky');
await queryInterface.removeColumn('user_notifications', 'show_in_notifications');
await queryInterface.removeColumn('admin_notifications', 'show_in_sticky');
await queryInterface.removeColumn('admin_notifications', 'show_in_notifications');
await queryInterface.removeColumn('notification_broadcasts', 'show_in_notifications');
await queryInterface.removeColumn('notification_broadcasts', 'show_in_sticky');
},
};
@@ -0,0 +1,30 @@
'use strict';
// Part 1A — new requirement types (submit_text, pass_quiz) + review workflow.
// `prompt` is submit_text's instructions field (mirrors link_label/reference_label
// as the type-specific display field). `requires_review` opts any submission-based
// requirement into admin approve/reject before it counts as complete.
// task_requirements.type has no DB-level CHECK constraint (verified against the
// live schema — Sequelize enforces the ENUM only at the application layer for
// this table), so no constraint migration is needed to add the new type values.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('task_requirements', 'prompt', {
type: Sequelize.TEXT,
allowNull: true,
after: 'reference_label',
});
await queryInterface.addColumn('task_requirements', 'requires_review', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
after: 'prompt',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('task_requirements', 'requires_review');
await queryInterface.removeColumn('task_requirements', 'prompt');
},
};
@@ -0,0 +1,55 @@
'use strict';
// Part 1A — completions gain a review workflow (submitted/approved/rejected)
// and a response_text field for the new submit_text requirement type.
// `status` is added as STRING + an explicit CHECK constraint, matching the
// pattern task_progress.type already uses on this CockroachDB instance
// (createTable auto-generates `check_type`; addColumn does not, so we add it
// ourselves) rather than the ENUM-with-no-constraint gap task_requirements.type
// happens to have today.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('task_completions', 'response_text', {
type: Sequelize.TEXT,
allowNull: true,
after: 'note',
});
await queryInterface.addColumn('task_completions', 'status', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'submitted',
after: 'response_text',
});
await queryInterface.sequelize.query(
`ALTER TABLE task_completions ADD CONSTRAINT check_status
CHECK (status IN ('submitted', 'approved', 'rejected'))`
);
await queryInterface.addColumn('task_completions', 'reviewed_by', {
type: Sequelize.BIGINT,
allowNull: true,
after: 'status',
});
await queryInterface.addColumn('task_completions', 'reviewed_at', {
type: Sequelize.DATE,
allowNull: true,
after: 'reviewed_by',
});
await queryInterface.addColumn('task_completions', 'review_note', {
type: Sequelize.TEXT,
allowNull: true,
after: 'reviewed_at',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('task_completions', 'review_note');
await queryInterface.removeColumn('task_completions', 'reviewed_at');
await queryInterface.removeColumn('task_completions', 'reviewed_by');
await queryInterface.sequelize.query(
`ALTER TABLE task_completions DROP CONSTRAINT IF EXISTS check_status`
);
await queryInterface.removeColumn('task_completions', 'status');
await queryInterface.removeColumn('task_completions', 'response_text');
},
};
@@ -0,0 +1,28 @@
'use strict';
// Part 1A — task_progress.type gains 'pass_quiz' (reference_id becomes a
// quiz_id for that type; completed is computed from QuizAttempt.passed the
// same way unit-quiz has_passed already is). Same drop/recreate CHECK
// constraint pattern as 20260101000058-add-exclusive-to-course-subscription.js.
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(
`ALTER TABLE task_progress DROP CONSTRAINT IF EXISTS check_type`
);
await queryInterface.sequelize.query(
`ALTER TABLE task_progress ADD CONSTRAINT check_type
CHECK (type IN ('read_course', 'read_unit', 'read_lesson', 'pass_quiz'))`
);
},
async down(queryInterface) {
await queryInterface.sequelize.query(
`ALTER TABLE task_progress DROP CONSTRAINT IF EXISTS check_type`
);
await queryInterface.sequelize.query(
`ALTER TABLE task_progress ADD CONSTRAINT check_type
CHECK (type IN ('read_course', 'read_unit', 'read_lesson'))`
);
},
};
@@ -0,0 +1,42 @@
'use strict';
// Part 1A/1C — new learner-facing notification types for the task review
// workflow, group assignment, and per-task completion. Seeded already-published
// (status: 'sent') like every other is_system row in the original
// 20260703000008 migration, so the trigger call sites work immediately without
// requiring an admin to publish them first.
module.exports = {
async up(queryInterface) {
const now = new Date();
await queryInterface.bulkInsert('notification_templates', [
{
type: 'task_submission_reviewed', notify_type: 'task', scope: 'user', is_system: true,
label: 'Task Submission Reviewed', status: 'sent',
title: 'Submission Reviewed',
message: 'Your submission for "{{taskName}}" was {{statusLabel}}.{{reviewNoteSuffix}}',
createdAt: now, updatedAt: now,
},
{
type: 'task_assigned', notify_type: 'task', scope: 'user', is_system: true,
label: 'Task List Assigned', status: 'sent',
title: 'New Task Assigned',
message: 'You have been assigned "{{taskListName}}" — {{taskCount}} task(s) to complete.',
createdAt: now, updatedAt: now,
},
{
type: 'task_completed', notify_type: 'task', scope: 'user', is_system: true,
label: 'Task Completed', status: 'sent',
title: 'Task Completed',
message: 'You completed "{{taskName}}".',
createdAt: now, updatedAt: now,
},
]);
},
async down(queryInterface) {
await queryInterface.bulkDelete('notification_templates', {
type: ['task_submission_reviewed', 'task_assigned', 'task_completed'],
});
},
};
@@ -0,0 +1,39 @@
'use strict';
// Part 1B — task_lists' tasks currently sort only by createdAt (no ordering
// column exists). Add order_index (position within the list) and is_required
// (mirrors unit_quizzes.is_required — an optional task doesn't block anything
// after it in the sequencing lock).
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tasks', 'order_index', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
after: 'deadline',
});
await queryInterface.addColumn('tasks', 'is_required', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
after: 'order_index',
});
// Backfill existing rows with a stable order matching current createdAt
// sort, so tasks don't all collapse to order_index 0 on first use.
await queryInterface.sequelize.query(`
UPDATE tasks t SET order_index = sub.rn - 1
FROM (
SELECT task_id, ROW_NUMBER() OVER (PARTITION BY task_list_id ORDER BY "createdAt" ASC) AS rn
FROM tasks
) sub
WHERE t.task_id = sub.task_id
`);
},
async down(queryInterface) {
await queryInterface.removeColumn('tasks', 'is_required');
await queryInterface.removeColumn('tasks', 'order_index');
},
};
@@ -0,0 +1,19 @@
'use strict';
// Part 2A — admin-authored bullet list ("what's included") per plan, replacing
// the client's hardcoded 4-item static perks list. Same JSONB-array-of-objects
// pattern already used for plan_policies.access_rules / payment_policies.promo_rules.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_plans', 'features', {
type: Sequelize.JSONB,
allowNull: true,
after: 'description',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_plans', 'features');
},
};
@@ -0,0 +1,23 @@
'use strict';
// Part 2B — standalone (non-course) units currently can never be tier-gated:
// canAccessUnit only ever checks course links, and an independent unit by
// definition has none. Add a direct, optional subscription field so a
// standalone unit can require a tier on its own. Deliberately no CHECK
// constraint — mirrors 20260101000065-drop-tier-enum-check-user-tiers.js's
// fix, since tier slugs are admin-defined (tier_categories.slug) and not a
// fixed enum.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('units', 'subscription', {
type: Sequelize.STRING(50),
allowNull: true,
after: 'title',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('units', 'subscription');
},
};