Files
pulse-signage/src/db/migrations.js
T

830 lines
31 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const { version: appVersion } = require('../../package.json');
function parseVersion(value) {
const parts = String(value || '0.0.0').split('.').map(function (part) {
return Math.max(0, Number(part) || 0);
});
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0
};
}
function compareVersions(left, right) {
const leftVersion = parseVersion(left);
const rightVersion = parseVersion(right);
if (leftVersion.major !== rightVersion.major) {
return leftVersion.major - rightVersion.major;
}
if (leftVersion.minor !== rightVersion.minor) {
return leftVersion.minor - rightVersion.minor;
}
if (leftVersion.patch !== rightVersion.patch) {
return leftVersion.patch - rightVersion.patch;
}
return 0;
}
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (rows.length && Number(rows[0].column_count) > 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
}
async function dropColumnIfPresent(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (!rows.length || Number(rows[0].column_count) === 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` DROP COLUMN \`${columnName}\``);
}
async function addForeignKeyIfMissing(pool, tableName, columnName, constraintName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS constraint_count
FROM information_schema.table_constraints
WHERE table_schema = DATABASE()
AND table_name = ?
AND constraint_name = ?`,
[tableName, constraintName]
);
if (rows.length && Number(rows[0].constraint_count) > 0) {
return;
}
await pool.query(
`ALTER TABLE \`${tableName}\`
ADD CONSTRAINT \`${constraintName}\`
FOREIGN KEY (\`${columnName}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
ON DELETE ${onDeleteAction}
ON UPDATE CASCADE`
);
}
async function hasSingleColumnUniqueIndex(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT INDEX_NAME, COUNT(*) AS column_count
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND non_unique = 0
AND column_name = ?
GROUP BY INDEX_NAME`,
[tableName, columnName]
);
return (rows || []).some(function (row) {
return Number(row.column_count) === 1;
});
}
async function addUniqueIndexIfMissing(pool, tableName, columnName, indexName) {
const hasUniqueIndex = await hasSingleColumnUniqueIndex(pool, tableName, columnName);
if (hasUniqueIndex) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE KEY \`${indexName}\` (\`${columnName}\`)`);
}
async function dedupePermissionRows(pool) {
const [rows] = await pool.query('SELECT id, permission_key FROM permissions ORDER BY id ASC');
const canonicalIdByKey = new Map();
const duplicateRowsByKey = new Map();
for (const row of rows || []) {
const permissionKey = String((row && row.permission_key) || '').trim().toLowerCase();
const permissionId = Number(row.id);
if (!permissionKey || !Number.isInteger(permissionId) || permissionId <= 0) {
continue;
}
if (!canonicalIdByKey.has(permissionKey)) {
canonicalIdByKey.set(permissionKey, permissionId);
continue;
}
if (!duplicateRowsByKey.has(permissionKey)) {
duplicateRowsByKey.set(permissionKey, []);
}
duplicateRowsByKey.get(permissionKey).push(permissionId);
}
if (!duplicateRowsByKey.size) {
return;
}
for (const [permissionKey, duplicateIds] of duplicateRowsByKey.entries()) {
const canonicalId = canonicalIdByKey.get(permissionKey);
for (const duplicateId of duplicateIds) {
await pool.query(
'UPDATE IGNORE role_permissions SET permission_id = ? WHERE permission_id = ?',
[canonicalId, duplicateId]
);
}
}
const duplicateIds = [];
for (const duplicateList of duplicateRowsByKey.values()) {
duplicateIds.push.apply(duplicateIds, duplicateList);
}
if (duplicateIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [duplicateIds]);
}
}
async function addAuditColumns(pool, tableName) {
await addColumnIfMissing(pool, tableName, 'created_by', 'INT NULL');
await addColumnIfMissing(pool, tableName, 'modified_by', 'INT NULL');
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users created_user ON created_user.id = t.created_by
SET t.created_by = NULL
WHERE t.created_by IS NOT NULL`
);
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users modified_user ON modified_user.id = t.modified_by
SET t.modified_by = NULL
WHERE t.modified_by IS NOT NULL`
);
await addForeignKeyIfMissing(pool, tableName, 'created_by', `fk_${tableName}_created_by`, 'users', 'id', 'SET NULL');
await addForeignKeyIfMissing(pool, tableName, 'modified_by', `fk_${tableName}_modified_by`, 'users', 'id', 'SET NULL');
}
async function hasColumn(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
return rows.length && Number(rows[0].column_count) > 0;
}
async function backfillLegacyRssFeedItemJson(pool) {
const [rows] = await pool.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'rss_feed_items'`
);
const columnNames = new Set((rows || []).map(function (row) {
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
}).filter(Boolean));
if (!['title', 'link', 'pub_date', 'description'].every(function (columnName) {
return columnNames.has(columnName);
})) {
return;
}
await pool.query(
`UPDATE rss_feed_items
SET item_json = JSON_OBJECT(
'title', title,
'link', link,
'pubDate', pub_date,
'description', description
)
WHERE item_json IS NULL`
);
}
async function backfillLegacySlideTemplateCanvasSize(pool) {
const [legacyTemplateColumns] = await pool.query(`
SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'slide_templates'
AND column_name IN ('canvas_width', 'canvas_height')
`);
if (!legacyTemplateColumns[0] || Number(legacyTemplateColumns[0].column_count) !== 2) {
return;
}
await pool.query(`
UPDATE slide_templates st
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
SET st.canvas_size_id = cs.id
WHERE st.canvas_size_id IS NULL
`);
}
function replaceUploadPrefixInValue(value) {
if (typeof value === 'string') {
return value.replace(/\/uploads\//g, '/media/');
}
if (Array.isArray(value)) {
return value.map(function (item) {
return replaceUploadPrefixInValue(item);
});
}
if (value && typeof value === 'object') {
return Object.keys(value).reduce(function (result, key) {
result[key] = replaceUploadPrefixInValue(value[key]);
return result;
}, {});
}
return value;
}
function replaceLegacyMediaUploadPathInValue(value) {
if (typeof value === 'string') {
if (!value.startsWith('/media/') || value.startsWith('/media/uploads/')) {
return value;
}
return '/media/uploads/' + path.basename(value);
}
if (Array.isArray(value)) {
return value.map(function (item) {
return replaceLegacyMediaUploadPathInValue(item);
});
}
if (value && typeof value === 'object') {
return Object.keys(value).reduce(function (result, key) {
result[key] = replaceLegacyMediaUploadPathInValue(value[key]);
return result;
}, {});
}
return value;
}
function parseJsonValue(value) {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return value;
}
}
async function backfillLegacyMediaPaths(pool) {
const [slides] = await pool.query(`
SELECT id, media_path, content_json
FROM slides
WHERE media_path LIKE '/uploads/%'
OR content_json LIKE '%/uploads/%'
`);
for (const slide of slides || []) {
let mediaPath = String(slide.media_path || '').trim() || null;
let contentJson = slide.content_json;
let changed = false;
if (mediaPath && mediaPath.startsWith('/uploads/')) {
mediaPath = mediaPath.replace(/^\/uploads\//, '/media/');
changed = true;
}
const parsedContent = parseJsonValue(contentJson);
if (parsedContent && typeof parsedContent === 'object') {
const updatedContent = replaceUploadPrefixInValue(parsedContent);
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
contentJson = JSON.stringify(updatedContent);
changed = true;
}
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/uploads/') !== -1) {
contentJson = parsedContent.replace(/\/uploads\//g, '/media/');
changed = true;
}
if (changed) {
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
}
}
const [templates] = await pool.query(`
SELECT id, background_image_path
FROM slide_templates
WHERE background_image_path LIKE '/uploads/%'
`);
for (const template of templates || []) {
const backgroundImagePath = String(template.background_image_path || '').trim();
if (!backgroundImagePath.startsWith('/uploads/')) {
continue;
}
await pool.query(
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
[backgroundImagePath.replace(/^\/uploads\//, '/media/'), template.id]
);
}
}
async function moveFileIfMissing(sourcePath, targetPath) {
try {
await fs.promises.access(targetPath, fs.constants.F_OK);
return false;
} catch (_error) {
// target does not exist
}
try {
await fs.promises.access(sourcePath, fs.constants.F_OK);
} catch (_error) {
return false;
}
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
try {
await fs.promises.rename(sourcePath, targetPath);
} catch (error) {
if (error && error.code === 'EXDEV') {
await fs.promises.copyFile(sourcePath, targetPath);
await fs.promises.unlink(sourcePath);
return true;
}
throw error;
}
return true;
}
async function backfillLegacyMediaUploadsToSubfolder(pool, mediaDir) {
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
if (!normalizedMediaDir) {
return;
}
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
await fs.promises.mkdir(uploadsDir, { recursive: true });
const [slides] = await pool.query(`
SELECT id, media_path, content_json
FROM slides
WHERE media_path LIKE '/media/%'
OR content_json LIKE '%/media/%'
`);
for (const slide of slides || []) {
let mediaPath = String(slide.media_path || '').trim() || null;
let contentJson = slide.content_json;
let changed = false;
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
const fileName = path.basename(mediaPath);
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
mediaPath = '/media/uploads/' + fileName;
changed = true;
}
const parsedContent = parseJsonValue(contentJson);
if (parsedContent && typeof parsedContent === 'object') {
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
contentJson = JSON.stringify(updatedContent);
changed = true;
}
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
if (updatedContent !== parsedContent) {
contentJson = updatedContent;
changed = true;
}
}
if (changed) {
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
}
}
const [templates] = await pool.query(`
SELECT id, background_image_path
FROM slide_templates
WHERE background_image_path LIKE '/media/%'
`);
for (const template of templates || []) {
const backgroundImagePath = String(template.background_image_path || '').trim();
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
continue;
}
const fileName = path.basename(backgroundImagePath);
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
await pool.query(
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
['/media/uploads/' + fileName, template.id]
);
}
}
async function backfillLooseMediaFilesToUploadsSubfolder(pool, mediaDir) {
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
if (!normalizedMediaDir) {
return;
}
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
await fs.promises.mkdir(uploadsDir, { recursive: true });
const directoryEntries = await fs.promises.readdir(normalizedMediaDir, { withFileTypes: true });
for (const entry of directoryEntries || []) {
if (!entry || !entry.isFile()) {
continue;
}
const fileName = String(entry.name || '').trim();
if (!fileName) {
continue;
}
const sourcePath = path.join(normalizedMediaDir, fileName);
const targetPath = path.join(uploadsDir, fileName);
await moveFileIfMissing(sourcePath, targetPath);
}
const [slides] = await pool.query(`
SELECT id, media_path, content_json
FROM slides
WHERE media_path LIKE '/media/%'
OR content_json LIKE '%/media/%'
`);
for (const slide of slides || []) {
let mediaPath = String(slide.media_path || '').trim() || null;
let contentJson = slide.content_json;
let changed = false;
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
mediaPath = '/media/uploads/' + path.basename(mediaPath);
changed = true;
}
const parsedContent = parseJsonValue(contentJson);
if (parsedContent && typeof parsedContent === 'object') {
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
contentJson = JSON.stringify(updatedContent);
changed = true;
}
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
if (updatedContent !== parsedContent) {
contentJson = updatedContent;
changed = true;
}
}
if (changed) {
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
}
}
const [templates] = await pool.query(`
SELECT id, background_image_path
FROM slide_templates
WHERE background_image_path LIKE '/media/%'
`);
for (const template of templates || []) {
const backgroundImagePath = String(template.background_image_path || '').trim();
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
continue;
}
const fileName = path.basename(backgroundImagePath);
await pool.query(
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
['/media/uploads/' + fileName, template.id]
);
}
}
async function ensureMigrationTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration_key VARCHAR(100) NOT NULL UNIQUE,
app_version VARCHAR(32) NOT NULL,
comment TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function getAppliedMigrationRows(pool) {
await ensureMigrationTable(pool);
const [rows] = await pool.query(
'SELECT migration_key, app_version, comment, applied_at FROM schema_migrations ORDER BY id ASC'
);
return rows || [];
}
function getLatestAppliedVersion(rows) {
let latestVersion = '0.0.0';
for (const row of rows || []) {
const candidateVersion = String(row.app_version || '0.0.0');
if (compareVersions(candidateVersion, latestVersion) > 0) {
latestVersion = candidateVersion;
}
}
return latestVersion;
}
async function recordMigration(pool, migration) {
await pool.query(
'INSERT IGNORE INTO schema_migrations (migration_key, app_version, comment) VALUES (?, ?, ?)',
[migration.key, migration.version, migration.comment]
);
}
const migrations = [
{
key: 'interval-value-rename',
version: appVersion,
comment: 'Rename RSS and API refresh interval columns to update_interval_value so seconds and minutes share one neutral numeric field.',
order: 10,
up: async function (pool) {
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
if (await hasColumn(pool, 'rss_feeds', 'update_interval_minutes')) {
await pool.query(`
UPDATE rss_feeds
SET update_interval_value = update_interval_minutes
`);
await dropColumnIfPresent(pool, 'rss_feeds', 'update_interval_minutes');
}
if (await hasColumn(pool, 'api_sources', 'update_interval_minutes')) {
await pool.query(`
UPDATE api_sources
SET update_interval_value = update_interval_minutes
`);
await dropColumnIfPresent(pool, 'api_sources', 'update_interval_minutes');
}
}
},
{
key: 'schema-columns-current',
version: appVersion,
comment: 'Add the current table columns and audit fields that define the released schema.',
order: 20,
up: async function (pool) {
// v1.4.6: keep the current canvas_sizes shape available in older databases.
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'canvas_sizes');
// v1.4.6: playlists gained fade_between_slides plus audit fields.
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlists');
// v1.4.6: slide_templates now store canvas sizing and background metadata.
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_templates');
await backfillLegacySlideTemplateCanvasSize(pool);
// v1.4.6: slide_template_regions gained font family and audit fields.
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_template_regions');
// v1.4.6: slides gained structured content and media fields.
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slides');
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlist_slides');
// v1.4.6: screens gained an optional playlist binding and audit fields.
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'screens');
// v1.4.6: RSS feeds now use a neutral interval value plus unit.
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'rss_feeds', 'item_limit', 'INT NOT NULL DEFAULT 1');
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'rss_feeds');
// v1.4.6: rss_feed_items stores normalized JSON snapshots.
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await backfillLegacyRssFeedItemJson(pool);
// v1.4.6: API sources now track their latest response snapshot.
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'api_sources', 'last_pulled_at', 'TIMESTAMP NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_pull_error', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_status', 'INT NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_content_type', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'api_sources');
// v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'player_onboarding_devices');
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'users');
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'roles');
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'permissions');
await dedupePermissionRows(pool);
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'role_permissions');
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'user_roles');
await addAuditColumns(pool, 'auth_sessions');
}
},
{
key: 'playlist-skip-unavailable-rtmp',
version: appVersion,
comment: 'Add the playlist flag that skips RTMP slides when streams are unavailable.',
order: 21,
up: async function (pool) {
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
}
},
{
key: 'slides-thumbnail-path-column',
version: appVersion,
comment: 'Backfill the slides.thumbnail_path column for databases that already recorded the broader schema migration.',
order: 22,
up: async function (pool) {
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
}
},
{
key: 'media-path-prefix-rename',
version: appVersion,
comment: 'Rename stored media URLs from /uploads to /media so existing slides and templates keep working after the storage root move.',
order: 25,
up: async function (pool) {
await backfillLegacyMediaPaths(pool);
}
},
{
key: 'media-upload-subfolder-move',
version: appVersion,
comment: 'Move existing upload files and references from the media root into media/uploads.',
order: 26,
up: async function (pool, options) {
await backfillLegacyMediaUploadsToSubfolder(pool, options && options.mediaDir);
}
},
{
key: 'media-upload-subfolder-rescue',
version: appVersion,
comment: 'Rescan loose media files and promote them into media/uploads.',
order: 27,
up: async function (pool, options) {
await backfillLooseMediaFilesToUploadsSubfolder(pool, options && options.mediaDir);
}
},
{
key: 'background-tasks-table',
version: appVersion,
comment: 'Persist background tasks so queued work survives a web restart.',
order: 30,
up: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT 'general',
status VARCHAR(20) NOT NULL,
payload_json MEDIUMTEXT NULL,
metadata_json MEDIUMTEXT NULL,
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
error_message MEDIUMTEXT NULL,
INDEX idx_background_tasks_status (status),
INDEX idx_background_tasks_key (task_key),
INDEX idx_background_tasks_type (task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
}
];
async function runMigrations(pool, options) {
const appliedRows = await getAppliedMigrationRows(pool);
const appliedKeys = new Set((appliedRows || []).map(function (row) {
return String(row.migration_key || '').trim();
}).filter(Boolean));
const pendingMigrations = migrations
.filter(function (migration) {
return compareVersions(migration.version, appVersion) <= 0
&& !appliedKeys.has(migration.key);
})
.sort(function (left, right) {
const versionOrder = compareVersions(left.version, right.version);
if (versionOrder !== 0) {
return versionOrder;
}
return Number(left.order || 0) - Number(right.order || 0);
});
for (const migration of pendingMigrations) {
await migration.up(pool, options || {});
await recordMigration(pool, migration);
}
}
module.exports = {
appVersion: appVersion,
compareVersions: compareVersions,
runMigrations: runMigrations
};