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 };