const mysql = require('mysql2/promise'); const { hashPassword } = require('./auth'); const { PERMISSIONS, DEFAULT_ROLE, normalizePermissionKeys } = require('./rbac'); 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 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 addUserAuditColumns(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 AND created_user.id IS 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 AND modified_user.id IS 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 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 = getPermissionKey(row); 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 pruneStaleOnboardingDevices(pool) { await pool.query( `DELETE FROM player_onboarding_devices WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)` ); } async function getTableColumnNames(pool, tableName) { const [rows] = await pool.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?`, [tableName] ); return new Set((rows || []).map(function (row) { return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase(); }).filter(Boolean)); } function getPermissionKey(row) { return String((row && row.permission_key) || '').trim().toLowerCase(); } function getLegacyPermissionTargets(permissionKey) { const normalizedKey = String(permissionKey || '').trim().toLowerCase(); const parts = normalizedKey.split('.'); if (parts.length !== 2) { return [normalizedKey].filter(Boolean); } const sectionKey = parts[0]; const actionKey = parts[1]; if (normalizedKey === 'screens.allow') { return ['clients.allow']; } if (actionKey === 'edit') { return [`${sectionKey}.update`]; } if (actionKey === 'update') { return [`${sectionKey}.update`]; } if (actionKey === 'view') { return [`${sectionKey}.read`]; } if (actionKey === 'manage') { return [`${sectionKey}.read`, `${sectionKey}.create`, `${sectionKey}.update`, `${sectionKey}.delete`]; } return [normalizedKey].filter(Boolean); } function buildPermissionSeedColumns(columnNames) { const columns = []; if (columnNames.has('permission_key')) { columns.push('permission_key'); } if (columnNames.has('name')) { columns.push('name'); } if (columnNames.has('section_name')) { columns.push('section_name'); } if (columnNames.has('description')) { columns.push('description'); } if (columnNames.has('created_by')) { columns.push('created_by'); } if (columnNames.has('modified_by')) { columns.push('modified_by'); } return columns; } async function backfillLegacyRbacSchema(pool) { const permissionColumnNames = await getTableColumnNames(pool, 'permissions'); const [permissionRows] = await pool.query('SELECT id, permission_key, name, section_name FROM permissions ORDER BY id ASC'); const [rolePermissionRows] = await pool.query( `SELECT rp.role_id, p.permission_key FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id` ); const permissionIdByKey = new Map(); for (const row of permissionRows || []) { const currentKey = getPermissionKey(row); if (currentKey) { permissionIdByKey.set(currentKey, Number(row.id)); } } const rolePermissionTargets = new Map(); const desiredPermissionKeys = new Set(PERMISSIONS.map(function (permission) { return permission.key; })); const legacyPermissionRowIds = []; function addRoleTarget(roleId, permissionKey) { const normalizedPermissionKey = String(permissionKey || '').trim().toLowerCase(); if (!normalizedPermissionKey) { return; } if (!rolePermissionTargets.has(roleId)) { rolePermissionTargets.set(roleId, new Set()); } rolePermissionTargets.get(roleId).add(normalizedPermissionKey); } for (const row of rolePermissionRows || []) { const currentKey = getPermissionKey(row); const targetKeys = getLegacyPermissionTargets(currentKey); if (currentKey.endsWith('.view') || currentKey.endsWith('.manage') || currentKey.endsWith('.edit') || currentKey === 'screens.allow') { for (const targetKey of targetKeys) { addRoleTarget(Number(row.role_id), targetKey); } } else { addRoleTarget(Number(row.role_id), currentKey); } } for (const row of permissionRows || []) { const currentKey = getPermissionKey(row); const permissionId = Number(row.id); if (currentKey.endsWith('.edit')) { const targetKey = currentKey.replace(/\.edit$/, '.update'); const targetPermissionId = permissionIdByKey.get(targetKey); if (targetPermissionId) { legacyPermissionRowIds.push(permissionId); } else if (targetKey) { await pool.query('UPDATE permissions SET permission_key = ? WHERE id = ?', [targetKey, permissionId]); permissionIdByKey.delete(currentKey); permissionIdByKey.set(targetKey, permissionId); } continue; } if (currentKey.endsWith('.view') || currentKey.endsWith('.manage') || currentKey.endsWith('.edit') || currentKey === 'screens.allow') { legacyPermissionRowIds.push(permissionId); } } const roleRows = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC').then(function (result) { return result[0] || []; }); const defaultRoleRow = roleRows.find(function (row) { return String(row.role_key || '').trim().toLowerCase() === DEFAULT_ROLE.key; }) || null; if (defaultRoleRow) { if (!rolePermissionTargets.has(Number(defaultRoleRow.id))) { rolePermissionTargets.set(Number(defaultRoleRow.id), new Set()); } const defaultPermissions = rolePermissionTargets.get(Number(defaultRoleRow.id)); for (const permission of PERMISSIONS) { defaultPermissions.add(permission.key); } } const seedColumns = buildPermissionSeedColumns(permissionColumnNames); if (!seedColumns.length) { throw new Error('permissions table is missing required columns.'); } for (const permission of PERMISSIONS) { const seedValues = []; if (permissionColumnNames.has('permission_key')) { seedValues.push(permission.key); } if (permissionColumnNames.has('name')) { seedValues.push(permission.name); } if (permissionColumnNames.has('section_name')) { seedValues.push(permission.sectionName); } if (permissionColumnNames.has('description')) { seedValues.push(permission.description || null); } if (permissionColumnNames.has('created_by')) { seedValues.push(null); } if (permissionColumnNames.has('modified_by')) { seedValues.push(null); } const updateAssignments = []; if (permissionColumnNames.has('name')) { updateAssignments.push('name = VALUES(name)'); } if (permissionColumnNames.has('section_name')) { updateAssignments.push('section_name = VALUES(section_name)'); } if (permissionColumnNames.has('description')) { updateAssignments.push('description = VALUES(description)'); } if (permissionColumnNames.has('permission_key')) { updateAssignments.push('permission_key = VALUES(permission_key)'); } await pool.query( `INSERT INTO permissions (${seedColumns.join(', ')}) VALUES (${seedColumns.map(function () { return '?'; }).join(', ')}) ON DUPLICATE KEY UPDATE ${updateAssignments.join(', ')}`, seedValues ); } if (legacyPermissionRowIds.length) { await pool.query('DELETE FROM permissions WHERE id IN (?)', [legacyPermissionRowIds]); } const [currentPermissionRows] = await pool.query('SELECT id, permission_key FROM permissions'); const permissionIdByKeyAfterBackfill = new Map(); for (const row of currentPermissionRows || []) { const currentKey = getPermissionKey(row); if (currentKey) { permissionIdByKeyAfterBackfill.set(currentKey, Number(row.id)); } } await pool.query('DELETE FROM role_permissions'); for (const [roleId, permissionKeys] of rolePermissionTargets.entries()) { const expandedPermissionKeys = normalizePermissionKeys(Array.from(permissionKeys.values())); for (const permissionKey of expandedPermissionKeys) { const permissionId = permissionIdByKeyAfterBackfill.get(permissionKey); if (!permissionId) { continue; } await pool.query( 'INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [Number(roleId), permissionId, null, null] ); } } const [roleRowsAfter] = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC'); for (const row of roleRowsAfter || []) { const currentKey = String(row.role_key || '').trim(); const isAdministratorsRole = String(row.name || '').trim().toLowerCase() === DEFAULT_ROLE.name.toLowerCase(); const expectedKey = isAdministratorsRole ? DEFAULT_ROLE.key : `role-${row.id}`; if (!currentKey || currentKey !== expectedKey) { await pool.query( 'UPDATE roles SET role_key = ?, name = ?, description = COALESCE(description, ?) WHERE id = ?', [expectedKey, String(row.name || '').trim() || DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, row.id] ); } } } 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_canvas_sizes_dimensions (width, height) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); await addUserAuditColumns(pool, 'canvas_sizes'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); 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 addUserAuditColumns(pool, 'playlists'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); 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 addUserAuditColumns(pool, 'slide_templates'); 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) `); 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) { 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 `); } 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, 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL'); await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); await addUserAuditColumns(pool, 'slide_template_regions'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); 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 addUserAuditColumns(pool, 'slides'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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 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 addUserAuditColumns(pool, 'playlist_slides'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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 addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL'); await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); await addUserAuditColumns(pool, 'screens'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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 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 addUserAuditColumns(pool, 'player_onboarding_devices'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); 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 addUserAuditColumns(pool, 'users'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); 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 addUserAuditColumns(pool, 'roles'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); 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 addUserAuditColumns(pool, 'permissions'); await dropColumnIfPresent(pool, 'permissions', 'perm_key'); await dedupePermissionRows(pool); await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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 addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); await addUserAuditColumns(pool, 'role_permissions'); 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, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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 addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); await addUserAuditColumns(pool, 'user_roles'); 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, last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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 addUserAuditColumns(pool, 'auth_sessions'); const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users'); if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) { const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin'; 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 backfillLegacyRbacSchema(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] ); } } } module.exports = { createPool, ensureSchema, pruneStaleOnboardingDevices };