// Ordered database migrations kept independent from the application version. const { version: appVersion } = require('#root/package.json'); const TIMETABLE_TIME_ZONE = 'Europe/London'; const APP_STATE_TABLE = 'o_app_state'; const APP_STATE_SCHEMA_VERSION_KEY = 'schema_version'; const VERSIONED_MIGRATIONS = [ { version: '2.1.0', label: 'v2.1.0 player registry schema', run: async function (pool) { // Keep the player registry as the source of truth for device metadata. if (!(await columnExists(pool, 'd_players', 'public_base_url'))) { await pool.query(` CREATE TABLE IF NOT EXISTS d_players ( device_id VARCHAR(128) PRIMARY KEY, public_base_url VARCHAR(512) NULL, internal_base_url VARCHAR(512) NULL, last_seen_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } // Store the player pointer on screens so we can resolve the player without needing a player-side screen_id. // This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'. if (!(await columnExists(pool, 'd_screens', 'player_id'))) { await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); } else { await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'"); const [playerColumnNullableRows] = await pool.query( `SELECT COUNT(*) AS nullable_count FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'd_screens' AND COLUMN_NAME = 'player_id' AND IS_NULLABLE = 'YES'` ); if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) { await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); } } const [screenPlayerUniqueRows] = await pool.query( `SELECT COUNT(*) AS index_count FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'd_screens' AND INDEX_NAME = 'uq_screens_player_id'` ); if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) { await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id'); } // Recreate the screen-to-player foreign key after the column exists and legacy data is copied over. await ensureForeignKey(pool, 'd_screens', 'fk_screens_player', 'player_id', 'd_players', 'device_id', 'RESTRICT'); if (await columnExists(pool, 'c_template_regions', 'font_family')) { await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family'); } } }, { version: '2.1.1', label: 'v2.1.1 API source schema', run: async function (pool) { await ensureColumn(pool, 'i_api_sources', 'auth_method', "VARCHAR(32) NOT NULL DEFAULT 'none'", 'api_url'); await ensureColumn(pool, 'i_api_sources', 'auth_username', 'VARCHAR(255) NULL', 'auth_method'); await ensureColumn(pool, 'i_api_sources', 'auth_password', 'MEDIUMTEXT NULL', 'auth_username'); await ensureColumn(pool, 'i_api_sources', 'auth_bearer_token', 'MEDIUMTEXT NULL', 'auth_password'); await ensureColumn(pool, 'i_api_sources', 'auth_header_name', 'VARCHAR(255) NULL', 'auth_bearer_token'); await ensureColumn(pool, 'i_api_sources', 'auth_header_value', 'MEDIUMTEXT NULL', 'auth_header_name'); await ensureColumn(pool, 'i_api_sources', 'items_path', 'VARCHAR(255) NULL', 'auth_header_value'); } }, { version: '2.2.0', label: 'v2.2.0 announcement schema', run: async function (pool) { await pool.query(` CREATE TABLE IF NOT EXISTS d_announcements ( id INT AUTO_INCREMENT PRIMARY KEY, message TEXT NOT NULL, short_label VARCHAR(255) NOT NULL DEFAULT '', announcement_type VARCHAR(32) NOT NULL DEFAULT 'lower-third', color_key VARCHAR(32) NOT NULL DEFAULT 'primary', icon_key VARCHAR(64) NOT NULL DEFAULT 'megaphone-fill', duration_seconds INT NULL, expires_at TIMESTAMP 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, INDEX idx_announcements_expires_at (expires_at), INDEX idx_announcements_modified_at (modified_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } }, { version: '2.4.0', label: 'v2.4.0 playlist schedule rules schema', run: async function (pool) { await pool.query(` CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules ( id INT AUTO_INCREMENT PRIMARY KEY, playlist_slide_id INT NOT NULL, position INT NOT NULL DEFAULT 0, start_datetime DATETIME NULL, end_datetime DATETIME NULL, start_time TIME NULL, 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_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE, INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); const legacyColumnNames = [ 'schedule_mode', 'schedule_start_datetime', 'schedule_end_datetime', 'schedule_start_time', 'schedule_end_time', 'schedule_days_json', 'schedule_rules_json' ]; const existingLegacyColumns = []; for (const columnName of legacyColumnNames) { if (await columnExists(pool, 'c_playlist_slides', columnName)) { existingLegacyColumns.push(columnName); } } const selectColumns = ['id'].concat(existingLegacyColumns).join(', '); const [legacyRows] = await pool.query('SELECT ' + selectColumns + ' FROM c_playlist_slides'); for (const row of legacyRows) { const rules = normalizeLegacyScheduleRules(row); if (rules.length) { for (let index = 0; index < rules.length; index += 1) { const rule = rules[index]; await pool.query( 'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)', [row.id, index, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.schedule_days_json ? JSON.stringify(rule.schedule_days_json) : null] ); } } } await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_rules_json'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_mode'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_datetime'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_datetime'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_time'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_time'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_days_json'); await pool.query(` CREATE TABLE IF NOT EXISTS d_announcement_screens ( announcement_id INT NOT NULL, screen_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 (announcement_id, screen_id), INDEX idx_announcement_screens_screen_id (screen_id), CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE, CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } }, { version: '2.4.2', label: 'v2.4.2 playlist canvas id schema', run: async function (pool) { await ensureColumn(pool, 'c_playlists', 'canvas_id', 'INT NULL', 'skip_unavailable_rtmp'); await ensureForeignKey(pool, 'c_playlists', 'fk_playlists_canvas', 'canvas_id', 'c_canvas_sizes', 'id', 'SET NULL'); await ensureColumn(pool, 'c_playlist_slides', 'disable_audio', 'TINYINT(1) NOT NULL DEFAULT 1', 'use_video_duration'); await pool.query(` UPDATE c_playlists p JOIN ( SELECT ps.playlist_id, COUNT(DISTINCT CONCAT(cs.width, 'x', cs.height)) AS signature_count, MIN(CONCAT(cs.width, 'x', cs.height)) AS canvas_signature FROM c_playlist_slides ps JOIN c_slides sl ON sl.id = ps.slide_id LEFT JOIN c_templates st ON st.id = sl.template_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id WHERE cs.width IS NOT NULL AND cs.height IS NOT NULL GROUP BY ps.playlist_id ) x ON x.playlist_id = p.id JOIN c_canvas_sizes cs ON CONCAT(cs.width, 'x', cs.height) = x.canvas_signature SET p.canvas_id = cs.id WHERE (p.canvas_id IS NULL) AND x.signature_count = 1 `); } }, { version: '2.5.1', label: 'v2.5.1 template animation json schema', run: async function (pool) { await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio'); } }, { version: '2.6.2', label: 'v2.6.2 player identity schema', run: async function (pool) { if (!(await columnExists(pool, 'd_players', 'device_id'))) { await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); if (!(await columnExists(pool, 'd_players', 'identifier'))) { await ensureColumn(pool, 'd_players', 'identifier', 'VARCHAR(128) NOT NULL UNIQUE', 'id'); } if (!(await columnExists(pool, 'd_players', 'id'))) { await ensureColumn(pool, 'd_players', 'id', 'INT NOT NULL AUTO_INCREMENT', null); await pool.query('ALTER TABLE d_players ADD PRIMARY KEY (id)'); } else if (!(await columnIsAutoIncrement(pool, 'd_players', 'id'))) { await pool.query('ALTER TABLE d_players MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT'); } await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); return; } await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); await pool.query('DROP TABLE IF EXISTS d_players_rebuild'); await pool.query(` CREATE TABLE d_players_rebuild ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, identifier VARCHAR(128) NOT NULL UNIQUE, public_base_url VARCHAR(512) NULL, internal_base_url VARCHAR(512) NULL, last_seen_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query(` INSERT INTO d_players_rebuild (identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at) SELECT DISTINCT device_id AS identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at FROM d_players ORDER BY COALESCE(created_at, modified_at, device_id), device_id `); await pool.query('DROP TEMPORARY TABLE IF EXISTS d_player_id_map'); await pool.query(` CREATE TEMPORARY TABLE d_player_id_map AS SELECT old_players.device_id AS old_device_id, rebuilt_players.id AS new_player_id FROM d_players old_players JOIN d_players_rebuild rebuilt_players ON rebuilt_players.identifier = old_players.device_id `); await pool.query( `UPDATE d_screens s JOIN d_player_id_map m ON m.old_device_id = CAST(s.player_id AS CHAR) SET s.player_id = m.new_player_id WHERE s.player_id IS NOT NULL` ); await pool.query('DROP TABLE d_players'); await pool.query('RENAME TABLE d_players_rebuild TO d_players'); await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); return; } }, { version: '2.6.3', label: 'v2.6.3 screen-player fk removal', run: async function (pool) { await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); if (await columnExists(pool, 'd_screens', 'player_id')) { await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); } } }, { version: '2.6.4', label: 'v2.6.4 drop screen player id', run: async function (pool) { await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); await dropColumnIfExists(pool, 'd_screens', 'player_id'); } }, { version: '2.6.16', label: 'v2.6.16 timetable timezone schema', run: async function (pool) { const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups'); const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); const tableName = timetableGroupsExists ? 'i_timetable_groups' : scheduleGroupsExists ? 'i_schedule_groups' : null; if (!tableName) { return; } await ensureColumn(pool, tableName, 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); } }, { version: '2.6.17', label: 'v2.6.17 timetable europe/london conversion', run: async function (pool) { const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups'); const timetableEntriesExists = await tableExists(pool, 'i_timetable_entries'); const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries'); const groupTableName = timetableGroupsExists ? 'i_timetable_groups' : scheduleGroupsExists ? 'i_schedule_groups' : null; const entryTableName = timetableEntriesExists ? 'i_timetable_entries' : scheduleEntriesExists ? 'i_schedule_entries' : null; if (!groupTableName || !entryTableName) { return; } await ensureColumn(pool, groupTableName, 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); await pool.query('UPDATE ' + groupTableName + ' SET timezone = ?', [TIMETABLE_TIME_ZONE]); const [rows] = await pool.query('SELECT id, start_datetime, end_datetime FROM ' + entryTableName + ' ORDER BY id ASC'); for (const row of rows) { const startDate = convertMigrationDateTimeFromTimeZone(row.start_datetime, TIMETABLE_TIME_ZONE); const endDate = row.end_datetime ? convertMigrationDateTimeFromTimeZone(row.end_datetime, TIMETABLE_TIME_ZONE) : null; await pool.query( 'UPDATE ' + entryTableName + ' SET start_datetime = ?, end_datetime = ? WHERE id = ?', [formatMigrationDateTimeUtc(startDate), endDate ? formatMigrationDateTimeUtc(endDate) : null, row.id] ); } } }, { version: '2.6.18', label: 'v2.6.18 timetable table rename', run: async function (pool) { const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups'); if (scheduleGroupsExists) { if (!timetableGroupsExists) { await pool.query('RENAME TABLE i_schedule_groups TO i_timetable_groups, i_schedule_entries TO i_timetable_entries'); await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); return; } await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); await pool.query(` INSERT IGNORE INTO i_timetable_groups (id, name, short_description, timezone, created_at, created_by, modified_at, modified_by) SELECT id, name, short_description, 'Europe/London' AS timezone, created_at, created_by, modified_at, modified_by FROM i_schedule_groups ORDER BY id ASC `); await pool.query(` INSERT IGNORE INTO i_timetable_entries (id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by) SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by FROM i_schedule_entries ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC `); const [groupRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups'); const [entryRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries'); const nextGroupId = Number(groupRows && groupRows[0] && groupRows[0].max_id) + 1; const nextEntryId = Number(entryRows && entryRows[0] && entryRows[0].max_id) + 1; await pool.query('ALTER TABLE i_timetable_groups AUTO_INCREMENT = ' + nextGroupId); await pool.query('ALTER TABLE i_timetable_entries AUTO_INCREMENT = ' + nextEntryId); await pool.query('DROP TABLE i_schedule_entries'); await pool.query('DROP TABLE i_schedule_groups'); } } }, { version: '2.8.0', label: 'v2.8.0 combined application schema', run: async function (pool) { await pool.query(` CREATE TABLE IF NOT EXISTS o_app_settings ( id BIGINT AUTO_INCREMENT PRIMARY KEY, setting_key VARCHAR(191) NOT NULL UNIQUE, setting_value JSON 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 o_app_state ( id BIGINT AUTO_INCREMENT PRIMARY KEY, state_key VARCHAR(191) NOT NULL UNIQUE, state_value MEDIUMTEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); const numericIdTables = [ ['d_announcement_screens', 'uq_announcement_screens_pair', '(announcement_id, screen_id)'], ['d_onboarding_devices', 'uq_onboarding_devices_device_id', '(device_id)'], ['a_role_permissions', 'uq_role_permissions_pair', '(role_id, permission_id)'], ['a_user_roles', 'uq_user_roles_pair', '(user_id, role_id)'], ['a_sessions', 'uq_sessions_hash', '(session_hash)'], ['o_app_state', 'uq_app_state_key', '(state_key)'] ]; for (const [tableName, uniqueKeyName, uniqueColumns] of numericIdTables) { if (!(await tableExists(pool, tableName)) || await columnExists(pool, tableName, 'id')) { continue; } await pool.query('ALTER TABLE ' + tableName + ' DROP PRIMARY KEY, ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST, ADD UNIQUE KEY ' + uniqueKeyName + ' ' + uniqueColumns); } await ensureColumn(pool, 'a_users', 'must_change_password', 'TINYINT(1) NOT NULL DEFAULT 0', 'password_iterations'); await pool.query(` CREATE TABLE IF NOT EXISTS a_login_attempts ( id BIGINT AUTO_INCREMENT PRIMARY KEY, rate_key VARCHAR(600) NOT NULL UNIQUE, failed_count INT NOT NULL DEFAULT 0, last_failed_at DATETIME NULL, locked_until DATETIME NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await ensureColumn(pool, 'a_users', 'account_locked', 'TINYINT(1) NOT NULL DEFAULT 0', 'must_change_password'); await ensureColumn(pool, 'a_sessions', 'ip_address', 'VARCHAR(255) NULL', 'user_id'); await ensureColumn(pool, 'a_sessions', 'user_agent', 'VARCHAR(512) NULL', 'ip_address'); await pool.query(` CREATE TABLE IF NOT EXISTS o_audit_events ( id BIGINT AUTO_INCREMENT PRIMARY KEY, occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, category VARCHAR(64) NOT NULL, event_type VARCHAR(128) NOT NULL, actor_user_id INT NULL, target_type VARCHAR(64) NULL, target_id VARCHAR(191) NULL, target_label VARCHAR(255) NULL, ip_address VARCHAR(255) NULL, user_agent VARCHAR(512) NULL, details_json JSON NULL, INDEX idx_audit_events_occurred_at (occurred_at), INDEX idx_audit_events_category_type (category, event_type), INDEX idx_audit_events_actor (actor_user_id), INDEX idx_audit_events_target (target_type, target_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query( `INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by) VALUES (?, ?, NULL, NULL) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, ['audit.retention_days', JSON.stringify(30)] ); const settings = [ ['audit.enabled', true], ['audit.categories', ['authentication', 'security', 'sessions', 'users', 'roles', 'system-settings']], ['audit.include_request_metadata', true] ]; for (const [key, value] of settings) { await pool.query( `INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by) VALUES (?, ?, NULL, NULL) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [key, JSON.stringify(value)] ); } await pool.query( `INSERT IGNORE INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by) VALUES (?, ?, ?, ?, NULL, NULL)`, ['audit-log.allow', 'Audit log', 'Settings', 'Download filtered audit events.'] ); await pool.query( `INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) SELECT roles.id, permissions.id, NULL, NULL FROM a_roles roles CROSS JOIN a_permissions permissions WHERE roles.role_key = 'administrators' AND permissions.permission_key = 'audit-log.allow'` ); } }, { version: '2.8.7', label: 'v2.8.7 API request and token authentication schema', run: async function (pool) { await ensureColumn(pool, 'i_api_sources', 'request_method', "VARCHAR(10) NOT NULL DEFAULT 'GET'", 'api_url'); await ensureColumn(pool, 'i_api_sources', 'request_body_json', 'MEDIUMTEXT NULL', 'request_method'); await ensureColumn(pool, 'i_api_sources', 'token_url', 'VARCHAR(1024) NULL', 'auth_header_value'); await ensureColumn(pool, 'i_api_sources', 'token_request_body_json', 'MEDIUMTEXT NULL', 'token_url'); await ensureColumn(pool, 'i_api_sources', 'token_response_path', "VARCHAR(255) NULL DEFAULT 'access_token'", 'token_request_body_json'); await ensureColumn(pool, 'i_api_sources', 'token_refresh_url', 'VARCHAR(1024) NULL', 'token_response_path'); await ensureColumn(pool, 'i_api_sources', 'token_refresh_request_body_json', 'MEDIUMTEXT NULL', 'token_refresh_url'); await ensureColumn(pool, 'i_api_sources', 'token_refresh_response_path', "VARCHAR(255) NULL DEFAULT 'refresh_token'", 'token_refresh_request_body_json'); await ensureColumn(pool, 'i_api_sources', 'token_header_name', "VARCHAR(255) NULL DEFAULT 'Authorization'", 'token_refresh_response_path'); await ensureColumn(pool, 'i_api_sources', 'token_header_prefix', "VARCHAR(64) NULL DEFAULT 'Bearer'", 'token_header_name'); } }, { version: '2.8.8', label: 'v2.8.8 weather locations and RSS collection timestamps schema', run: async function (pool) { await pool.query(` CREATE TABLE IF NOT EXISTS i_weather_locations ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, location_label VARCHAR(255) NOT NULL, latitude DECIMAL(9,6) NOT NULL, longitude DECIMAL(9,6) NOT NULL, timezone VARCHAR(128) NOT NULL, provider VARCHAR(32) NOT NULL DEFAULT 'open-meteo', temperature_unit VARCHAR(16) NOT NULL DEFAULT 'celsius', wind_unit VARCHAR(16) NOT NULL DEFAULT 'kmh', precipitation_unit VARCHAR(16) NOT NULL DEFAULT 'mm', update_interval_value INT NOT NULL DEFAULT 30, update_interval_unit VARCHAR(16) NOT NULL DEFAULT 'minutes', last_pulled_at DATETIME NULL, last_pull_error VARCHAR(1024) 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, INDEX idx_weather_locations_modified_at (modified_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await ensureColumn(pool, 'i_rss_feeds', 'last_pulled_at', 'DATETIME NULL', 'item_limit'); } }, { version: '2.8.9', label: 'v2.8.9 data source enablement schema', run: async function (pool) { await ensureColumn(pool, 'i_api_sources', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'api_url'); await ensureColumn(pool, 'i_rss_feeds', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'feed_url'); await ensureColumn(pool, 'i_weather_locations', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'precipitation_unit'); } }, { version: '2.10.1', label: 'v2.10.1 template background gradient schema', run: async function (pool) { await ensureColumn(pool, 'c_templates', 'background_gradient', 'LONGTEXT NULL', 'background_color'); } }, { version: '2.10.2', label: 'v2.10.2 onboarding client last-seen schema', run: async function (pool) { await ensureColumn(pool, 'd_onboarding_devices', 'last_seen_at', 'TIMESTAMP NULL', 'screen_id'); } }, { version: '2.10.7', label: 'v2.10.7 API token refresh settings schema', run: async function (pool) { await ensureColumn(pool, 'i_api_sources', 'token_refresh_url', 'VARCHAR(1024) NULL', 'token_response_path'); await ensureColumn(pool, 'i_api_sources', 'token_refresh_request_body_json', 'MEDIUMTEXT NULL', 'token_refresh_url'); await ensureColumn(pool, 'i_api_sources', 'token_refresh_response_path', "VARCHAR(255) NULL DEFAULT 'refresh_token'", 'token_refresh_request_body_json'); } }, { version: '2.11.0', label: 'v2.11.0 account email and password reset schema', run: async function (pool) { await ensureColumn(pool, 'a_users', 'email', 'VARCHAR(320) NULL', 'username'); await ensureColumn(pool, 'a_users', 'email_verified_at', 'DATETIME NULL', 'email'); await ensureColumn(pool, 'a_users', 'pending_email', 'VARCHAR(320) NULL', 'email_verified_at'); await ensureColumn(pool, 'a_users', 'pending_email_token_hash', 'CHAR(64) NULL', 'pending_email'); await ensureColumn(pool, 'a_users', 'pending_email_expires_at', 'DATETIME NULL', 'pending_email_token_hash'); await ensureColumn(pool, 'a_users', 'last_login_at', 'DATETIME NULL', 'modified_by'); await ensureColumn(pool, 'a_users', 'last_login_ip', 'VARCHAR(255) NULL', 'last_login_at'); await pool.query(` CREATE TABLE IF NOT EXISTS a_account_tokens ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token_type VARCHAR(32) NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, expires_at DATETIME NOT NULL, used_at DATETIME NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_account_tokens_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE, INDEX idx_account_tokens_lookup (token_type, token_hash, expires_at), INDEX idx_account_tokens_user_type (user_id, token_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } }, { version: '2.11.1', label: 'v2.11.1 user invitations schema', run: async function (pool) { await pool.query(` CREATE TABLE IF NOT EXISTS a_user_invitations ( id BIGINT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(320) NOT NULL, name VARCHAR(255) NULL, role_ids_json TEXT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, expires_at DATETIME NOT NULL, used_at DATETIME NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, INDEX idx_user_invitations_email (email, used_at, expires_at), INDEX idx_user_invitations_created_by (created_by, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } }, { version: '2.13.0', label: 'v2.13.0 media library schema', run: async function (pool) { await pool.query(` CREATE TABLE IF NOT EXISTS c_media_assets ( id BIGINT AUTO_INCREMENT PRIMARY KEY, media_path VARCHAR(512) NOT NULL UNIQUE, original_name VARCHAR(255) NOT NULL, media_type VARCHAR(16) NOT NULL, mime_type VARCHAR(128) NOT NULL, file_size BIGINT UNSIGNED 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, INDEX idx_c_media_assets_type (media_type), INDEX idx_c_media_assets_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } }, { version: '2.13.1', label: 'v2.13.1 pending media upload visibility', run: async function (pool) { if (!(await columnExists(pool, 'c_media_assets', 'is_published'))) { await pool.query('ALTER TABLE c_media_assets ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 1 AFTER file_size'); } } } ]; async function columnExists(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 Number(rows && rows[0] && rows[0].column_count) > 0; } async function tableExists(pool, tableName) { const [rows] = await pool.query( `SELECT COUNT(*) AS table_count FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, [tableName] ); return Number(rows && rows[0] && rows[0].table_count) > 0; } async function detectSchemaVersion(pool) { if (await tableExists(pool, APP_STATE_TABLE)) { const [rows] = await pool.query( 'SELECT state_value FROM ' + APP_STATE_TABLE + ' WHERE state_key = ? LIMIT 1', [APP_STATE_SCHEMA_VERSION_KEY] ); const storedVersion = String(rows && rows[0] && rows[0].state_value || '').trim(); if (storedVersion) { return storedVersion; } } const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups'); const timetableEntriesExists = await tableExists(pool, 'i_timetable_entries'); const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries'); if (timetableGroupsExists && timetableEntriesExists && !scheduleGroupsExists && !scheduleEntriesExists) { return '2.6.18'; } return '0.0.0'; } async function recordSchemaVersion(pool, version) { await pool.query( `CREATE TABLE IF NOT EXISTS ${APP_STATE_TABLE} ( id BIGINT AUTO_INCREMENT PRIMARY KEY, state_key VARCHAR(191) NOT NULL UNIQUE, state_value MEDIUMTEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` ); const stateValue = String(version || appVersion || '0.0.0').trim(); await pool.query( 'UPDATE ' + APP_STATE_TABLE + ' SET state_value = ? WHERE state_key = ?', [stateValue, APP_STATE_SCHEMA_VERSION_KEY] ); await pool.query( 'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ' + APP_STATE_TABLE + ' WHERE state_key = ?)', [APP_STATE_SCHEMA_VERSION_KEY, stateValue, APP_STATE_SCHEMA_VERSION_KEY] ); } async function columnIsAutoIncrement(pool, tableName, columnName) { const [rows] = await pool.query( `SELECT COUNT(*) AS auto_increment_count FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? AND EXTRA LIKE '%auto_increment%'`, [tableName, columnName] ); return Number(rows && rows[0] && rows[0].auto_increment_count) > 0; } async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) { if (await columnExists(pool, tableName, columnName)) { return; } const afterClause = afterColumn ? ' AFTER ' + afterColumn : ''; try { await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause); } catch (error) { if (!error || (error.code !== 'ER_DUP_FIELDNAME' && error.errno !== 1060)) { throw error; } } } async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) { const [existingFkRows] = await pool.query( `SELECT COUNT(*) AS fk_count FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL`, [tableName, columnName] ); if (Number(existingFkRows && existingFkRows[0] && existingFkRows[0].fk_count) > 0) { return; } const [rows] = await pool.query( `SELECT COUNT(*) AS fk_count FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND CONSTRAINT_NAME = ?`, [constraintName] ); if (Number(rows && rows[0] && rows[0].fk_count) > 0) { return; } const fallbackNames = [ constraintName, tableName + '_' + columnName + '_fk', tableName + '_' + columnName + '_fk_2', tableName + '_' + columnName + '_fk_3' ]; for (const candidateName of fallbackNames) { try { await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction); return; } catch (error) { if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826 && error.errno !== 121)) { throw error; } } } } async function dropForeignKeyIfExists(pool, tableName, columnName) { const [rows] = await pool.query( `SELECT CONSTRAINT_NAME AS constraint_name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1`, [tableName, columnName] ); const constraintName = String(rows && rows[0] && rows[0].constraint_name || '').trim(); if (!constraintName) { return; } try { await pool.query('ALTER TABLE ' + tableName + ' DROP FOREIGN KEY ' + constraintName); } catch (error) { if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) { throw error; } } } async function dropColumnIfExists(pool, tableName, columnName) { if (await columnExists(pool, tableName, columnName)) { try { await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName); } catch (error) { if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) { throw error; } } } } function normalizeLegacyScheduleRules(row) { const rules = []; if (!row) { return rules; } if (row.schedule_rules_json) { try { const parsed = JSON.parse(row.schedule_rules_json); if (Array.isArray(parsed)) { parsed.forEach(function (rule) { const normalized = normalizeScheduleRule(rule); if (normalized) { rules.push(normalized); } }); return rules; } } catch (_error) { // fall through to legacy columns } } const mode = String(row.schedule_mode || 'always').trim().toLowerCase(); if (mode === 'dates' && row.schedule_start_datetime && row.schedule_end_datetime) { rules.push({ start_datetime: formatMigrationDateTime(row.schedule_start_datetime), end_datetime: formatMigrationDateTime(row.schedule_end_datetime) }); return rules; } if (mode === 'times' && row.schedule_start_time && row.schedule_end_time) { const days = normalizeMigrationDays(row.schedule_days_json); const rule = { start_time: formatMigrationTime(row.schedule_start_time), end_time: formatMigrationTime(row.schedule_end_time) }; if (days.length) { rule.schedule_days_json = days; } rules.push(rule); } return rules; } function normalizeScheduleRule(rule) { if (!rule || typeof rule !== 'object') { return null; } const normalized = {}; if (rule.start_datetime) { normalized.start_datetime = String(rule.start_datetime); } if (rule.end_datetime) { normalized.end_datetime = String(rule.end_datetime); } if (rule.start_time) { normalized.start_time = String(rule.start_time).slice(0, 5); } if (rule.end_time) { normalized.end_time = String(rule.end_time).slice(0, 5); } if (Array.isArray(rule.schedule_days_json)) { normalized.schedule_days_json = rule.schedule_days_json; } else if (Array.isArray(rule.days)) { normalized.schedule_days_json = rule.days; } return Object.keys(normalized).length ? normalized : null; } function formatMigrationDateTime(value) { if (!value) { return null; } const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { return null; } const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes; } function parseMigrationDateTimeParts(value) { if (!value) { return null; } if (value instanceof Date) { if (Number.isNaN(value.getTime())) { return null; } return { year: value.getUTCFullYear(), month: value.getUTCMonth() + 1, day: value.getUTCDate(), hour: value.getUTCHours(), minute: value.getUTCMinutes(), second: value.getUTCSeconds() }; } const raw = String(value || '').trim(); const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/); if (!match) { return null; } return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]), hour: Number(match[4] || 0), minute: Number(match[5] || 0), second: Number(match[6] || 0) }; } function getMigrationTimeZoneOffsetMillis(date, timeZone) { if (!(date instanceof Date) || Number.isNaN(date.getTime())) { return 0; } const parts = new Intl.DateTimeFormat('en-GB', { timeZone: timeZone, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(date).reduce(function (acc, part) { if (part && part.type && part.type !== 'literal') { acc[part.type] = part.value; } return acc; }, Object.create(null)); const localAsUtc = Date.UTC( Number(parts.year) || 0, (Number(parts.month) || 1) - 1, Number(parts.day) || 1, Number(parts.hour) || 0, Number(parts.minute) || 0, Number(parts.second) || 0, 0 ); return localAsUtc - date.getTime(); } function convertMigrationDateTimeFromTimeZone(value, timeZone) { const parts = parseMigrationDateTimeParts(value); if (!parts) { return null; } const utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0); let adjusted = new Date(utcMillis - getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone)); const adjustedOffset = getMigrationTimeZoneOffsetMillis(adjusted, timeZone); if (adjustedOffset !== getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone)) { adjusted = new Date(utcMillis - adjustedOffset); } return adjusted; } function formatMigrationDateTimeUtc(value) { if (!(value instanceof Date) || Number.isNaN(value.getTime())) { return null; } const year = value.getUTCFullYear(); const month = String(value.getUTCMonth() + 1).padStart(2, '0'); const day = String(value.getUTCDate()).padStart(2, '0'); const hours = String(value.getUTCHours()).padStart(2, '0'); const minutes = String(value.getUTCMinutes()).padStart(2, '0'); const seconds = String(value.getUTCSeconds()).padStart(2, '0'); return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds; } function formatMigrationTime(value) { if (!value) { return null; } return String(value).slice(0, 5); } function normalizeMigrationDays(value) { let parsed = []; if (Array.isArray(value)) { parsed = value; } else if (value) { try { const raw = typeof value === 'string' ? JSON.parse(value) : value; parsed = Array.isArray(raw) ? raw : []; } catch (_error) { parsed = []; } } return Array.from(new Set(parsed.map(function (day) { return Number(day); }).filter(function (day) { return Number.isInteger(day) && day >= 0 && day <= 6; }))).sort(function (left, right) { return left - right; }); } function compareVersions(leftVersion, rightVersion) { const leftParts = String(leftVersion || '0.0.0').split('.').map(function (value) { return Number(value) || 0; }); const rightParts = String(rightVersion || '0.0.0').split('.').map(function (value) { return Number(value) || 0; }); const length = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < length; index += 1) { const leftPart = leftParts[index] || 0; const rightPart = rightParts[index] || 0; if (leftPart > rightPart) { return 1; } if (leftPart < rightPart) { return -1; } } return 0; } async function getPendingMigrations(pool, options) { const targetVersion = String(appVersion || '0.0.0').trim(); const currentVersion = String(options && options.currentVersion || '0.0.0').trim(); const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id'); const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id'); const legacyTimetableGroupsPresent = await tableExists(pool, 'i_schedule_groups'); const legacyTimetableEntriesPresent = await tableExists(pool, 'i_schedule_entries'); let effectiveCurrentVersion = currentVersion; if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) { effectiveCurrentVersion = '2.1.0'; } if (!screenPlayerColumnPresent && compareVersions(effectiveCurrentVersion, '2.6.3') < 0) { effectiveCurrentVersion = '2.6.3'; } if (legacyTimetableGroupsPresent || legacyTimetableEntriesPresent) { effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17'; } return VERSIONED_MIGRATIONS.filter(function (migration) { return compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0; }); } async function runMigrations(pool, options) { // Only run migrations that are newer than the installed schema version and not beyond the app version. const pendingMigrations = await getPendingMigrations(pool, options); for (const migration of pendingMigrations) { await migration.run(pool); } } module.exports = { appVersion: appVersion, getPendingMigrations: getPendingMigrations, runMigrations: runMigrations, detectSchemaVersion: detectSchemaVersion, recordSchemaVersion: recordSchemaVersion, compareVersions: compareVersions };