Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 12:39:17 +08:00
parent 82ea9c77c4
commit ea3e82e54c
47 changed files with 1301 additions and 481 deletions
@@ -0,0 +1,37 @@
'use strict';
// Re-categorizes advertisement placements: Hero stays on Dashboard, Banner
// consolidates onto Tier Plans + Course Details. Popup (dashboard.popup) and
// Sidebar (course_details.sidebar) are retired as formats, and the Courses
// list banner (course_list.banner) is dropped along with them since the new
// registry only offers Dashboard / Tier Plans / Course Details.
//
// Existing rows on a retired placement are archived (soft-deleted) rather
// than deleted outright — they still show up in Archived Advertisements for
// an admin to review/permanently delete if desired.
const RETIRED_PLACEMENTS = ['dashboard.popup', 'course_list.banner', 'course_details.sidebar'];
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE advertisements SET placement = 'tier_plans.banner' WHERE placement = 'plans.banner'
`);
await queryInterface.sequelize.query(`
UPDATE advertisements
SET "deletedAt" = NOW()
WHERE placement IN (:retired) AND "deletedAt" IS NULL
`, {
replacements: { retired: RETIRED_PLACEMENTS },
});
},
async down(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE advertisements SET placement = 'plans.banner' WHERE placement = 'tier_plans.banner'
`);
// Retired-placement rows that were auto-archived by `up` are intentionally
// left archived — there's no reliable way to distinguish them from ads an
// admin archived manually in the meantime.
},
};
@@ -0,0 +1,55 @@
'use strict';
// Supports the new advertisement creation wizard:
// - content_mode: "image" (image only) vs "content" (badge/headline/
// description/CTAs alongside the image) — an explicit admin choice,
// decoupled from placement/format. Added as STRING + an explicit CHECK
// constraint (addColumn can't create a native enum type on this
// CockroachDB instance, unlike createTable) — matching the pattern used
// in 20260710000001-add-status-to-courses.js. The Sequelize model still
// declares it as DataTypes.ENUM for app-level validation; the column
// itself is a plain string.
// - redirect_link: where the ad as a whole links to when clicked with no
// CTA of its own (banners/full-image ads have no CTA row).
// - landing_page: when no redirect_link is given, an internally-authored
// landing page (title/description/body/links) the ad's own click-through
// resolves to instead (see GET /api/client/advertisements/uuid/:uuid and
// the /ads/:uuid client route).
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('advertisements', 'content_mode', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'image',
});
await queryInterface.sequelize.query(`
ALTER TABLE advertisements ADD CONSTRAINT check_content_mode
CHECK (content_mode IN ('image', 'content'))
`);
await queryInterface.addColumn('advertisements', 'redirect_link', {
type: Sequelize.STRING(512),
allowNull: true,
});
await queryInterface.addColumn('advertisements', 'landing_page', {
type: Sequelize.JSONB,
allowNull: true,
});
// Existing rows already have real content (headline/description/ctas) —
// treat them as "content" mode rather than defaulting to "image".
await queryInterface.sequelize.query(`
UPDATE advertisements
SET content_mode = 'content'
WHERE headline IS NOT NULL AND headline <> ''
`);
},
async down(queryInterface) {
await queryInterface.removeColumn('advertisements', 'landing_page');
await queryInterface.removeColumn('advertisements', 'redirect_link');
await queryInterface.sequelize.query(`ALTER TABLE advertisements DROP CONSTRAINT IF EXISTS check_content_mode`);
await queryInterface.removeColumn('advertisements', 'content_mode');
},
};
@@ -0,0 +1,32 @@
'use strict';
// Reverts the notification_templates feature (20260703000008 +
// 20260709000004) — admin-editable notification wording has been moved back
// to hardcoded build() functions in data/notifications.data.js.
module.exports = {
async up(queryInterface) {
await queryInterface.dropTable('notification_templates');
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_scope";`);
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_status";`);
},
async down(queryInterface, Sequelize) {
await queryInterface.createTable('notification_templates', {
notification_template_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
type: { type: Sequelize.STRING(100), allowNull: false, unique: true },
notify_type: { type: Sequelize.STRING(64), allowNull: false },
scope: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false },
label: { type: Sequelize.STRING(150), allowNull: false },
status: { type: Sequelize.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft' },
title: { type: Sequelize.STRING(255), allowNull: true },
message: { type: Sequelize.TEXT, allowNull: true },
draft_title: { type: Sequelize.STRING(255), allowNull: true },
draft_message: { type: Sequelize.TEXT, allowNull: true },
last_sent_at: { type: Sequelize.DATE, allowNull: true },
is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
};
@@ -0,0 +1,35 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('notification_broadcasts', 'color', {
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: 'indigo',
});
await queryInterface.addColumn('notification_broadcasts', 'link_label', {
type: Sequelize.STRING(60),
allowNull: true,
});
await queryInterface.addColumn('admin_notifications', 'color', {
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: 'indigo',
});
await queryInterface.addColumn('user_notifications', 'color', {
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: 'indigo',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('user_notifications', 'color');
await queryInterface.removeColumn('admin_notifications', 'color');
await queryInterface.removeColumn('notification_broadcasts', 'link_label');
await queryInterface.removeColumn('notification_broadcasts', 'color');
},
};
@@ -0,0 +1,44 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('notification_broadcasts', 'start_date', {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn('notification_broadcasts', 'end_date', {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn('admin_notifications', 'start_date', {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn('admin_notifications', 'end_date', {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn('user_notifications', 'start_date', {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn('user_notifications', 'end_date', {
type: Sequelize.DATE,
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('user_notifications', 'end_date');
await queryInterface.removeColumn('user_notifications', 'start_date');
await queryInterface.removeColumn('admin_notifications', 'end_date');
await queryInterface.removeColumn('admin_notifications', 'start_date');
await queryInterface.removeColumn('notification_broadcasts', 'end_date');
await queryInterface.removeColumn('notification_broadcasts', 'start_date');
},
};
@@ -0,0 +1,24 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('admin_notifications', 'broadcast_id', {
type: Sequelize.BIGINT,
allowNull: true,
references: { model: 'notification_broadcasts', key: 'broadcast_id' },
onDelete: 'SET NULL',
});
await queryInterface.addColumn('user_notifications', 'broadcast_id', {
type: Sequelize.BIGINT,
allowNull: true,
references: { model: 'notification_broadcasts', key: 'broadcast_id' },
onDelete: 'SET NULL',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('user_notifications', 'broadcast_id');
await queryInterface.removeColumn('admin_notifications', 'broadcast_id');
},
};
@@ -0,0 +1,42 @@
'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), same pragmatic approach as the "no need for a
// dedicated Banner entity" ask — 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');
},
};