830 lines
31 KiB
JavaScript
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
|
|
}; |