1125 lines
45 KiB
JavaScript
1125 lines
45 KiB
JavaScript
// 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
|
|
};
|