Save worktree changes

This commit is contained in:
2026-07-25 02:29:19 +01:00
parent 8d3b7d557b
commit db9d718cd8
170 changed files with 11719 additions and 3414 deletions
+379
View File
@@ -0,0 +1,379 @@
const mysql = require('mysql2/promise');
const { hashPassword } = require('../auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
const migrations = require('./migrations');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM player_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
async function ensureSchema(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS playlists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL,
background_image_path VARCHAR(512) NULL,
background_color VARCHAR(32) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
('Portrait Full HD', 1080, 1920),
('Portrait HD', 720, 1280)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
region_key VARCHAR(100) NOT NULL,
region_type VARCHAR(20) NOT NULL,
label VARCHAR(255) NOT NULL,
lock_ratio VARCHAR(20) NULL,
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100,
height INT NOT NULL DEFAULT 100,
z_index INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slides (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
template_id INT NULL,
content_json JSON NULL,
media_path VARCHAR(512) NULL,
media_type VARCHAR(100) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL DEFAULT 10,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL,
schedule_start_time TIME NULL,
schedule_end_time TIME NULL,
schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS screens (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feeds (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
feed_url VARCHAR(1024) NOT NULL,
update_interval_value INT NOT NULL DEFAULT 60,
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
item_limit INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feed_items (
id INT AUTO_INCREMENT PRIMARY KEY,
rss_feed_id INT NOT NULL,
position INT NOT NULL,
item_json MEDIUMTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE,
UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS api_sources (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
api_url VARCHAR(1024) NOT NULL,
update_interval_value INT NOT NULL DEFAULT 60,
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
last_pulled_at TIMESTAMP NULL,
last_pull_error MEDIUMTEXT NULL,
last_response_status INT NULL,
last_response_content_type VARCHAR(255) NULL,
last_response_json MEDIUMTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY,
client_name VARCHAR(255) NULL,
screen_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL,
username VARCHAR(255) NOT NULL UNIQUE,
password_hash CHAR(64) NOT NULL,
password_salt VARCHAR(64) NOT NULL,
password_iterations INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
section_name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO permissions (permission_key, name, section_name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
);
}
await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
session_hash CHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
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
`);
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
await migrations.runMigrations(pool);
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM users`,
[defaultRoleId]
);
}
}
const [defaultAdminRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (defaultAdminRows.length) {
const defaultAdminId = Number(defaultAdminRows[0].id);
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM user_roles WHERE user_id = ?', [defaultAdminId]);
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
'INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[defaultAdminId, defaultRoleId, null, null]
);
}
}
}
}
module.exports = {
createPool,
ensureSchema,
pruneStaleOnboardingDevices
};
+588
View File
@@ -0,0 +1,588 @@
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 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 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', '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', '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: '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: '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) {
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);
await recordMigration(pool, migration);
}
}
module.exports = {
appVersion: appVersion,
compareVersions: compareVersions,
runMigrations: runMigrations
};