function normalizeDeviceId(value) { return String(value || '') .trim() .replace(/[^a-zA-Z0-9_-]/g, '') .slice(0, 128); } function normalizeBaseUrl(value) { return String(value || '').trim().replace(/\/$/, ''); } function normalizeIdentifier(value) { return String(value || '').trim().slice(0, 255); } 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 fetchPlayerRegistrations(pool) { if (!pool) { return []; } const [rows] = await pool.query( `SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, modified_at FROM d_players ORDER BY modified_at DESC, identifier ASC` ); return rows; } function getConfiguredPlayerIdentifier() { return normalizeDeviceId(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || ''); } async function resolvePlayerRegistration(pool, identifier) { const normalizedIdentifier = normalizeDeviceId(identifier); if (!pool || !normalizedIdentifier) { return null; } const hasIdentifierColumn = await columnExists(pool, 'd_players', 'identifier'); const hasDeviceIdColumn = await columnExists(pool, 'd_players', 'device_id'); const identifierColumn = hasIdentifierColumn ? 'identifier' : (hasDeviceIdColumn ? 'device_id' : ''); if (!identifierColumn) { return null; } const selectIdExpression = hasIdentifierColumn ? 'id' : 'NULL AS id'; const selectIdentifierExpression = hasIdentifierColumn ? 'identifier' : 'device_id AS identifier'; const orderByExpression = hasIdentifierColumn ? 'modified_at DESC, id DESC' : 'modified_at DESC'; const [rows] = await pool.query( `SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at FROM d_players WHERE ${identifierColumn} = ? LIMIT 1`, [normalizedIdentifier] ); if (rows[0]) { return rows[0]; } const [fallbackRows] = await pool.query( `SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at FROM d_players ORDER BY ${orderByExpression} LIMIT 1` ); return fallbackRows[0] || null; } async function upsertPlayerRegistration(pool, options) { const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId)); const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl); const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl); if (!pool || !identifier) { return null; } await pool.query( `INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`, [identifier, publicBaseUrl || null, internalBaseUrl || null] ); return resolvePlayerRegistration(pool, identifier); } async function recordPlayerHeartbeat(pool, options) { const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId)); const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl); const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl); if (!pool || !identifier) { return null; } await pool.query( `INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE public_base_url = COALESCE(VALUES(public_base_url), public_base_url), internal_base_url = COALESCE(VALUES(internal_base_url), internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`, [identifier, publicBaseUrl || null, internalBaseUrl || null] ); return resolvePlayerRegistration(pool, identifier); } module.exports = { normalizeDeviceId: normalizeDeviceId, normalizeIdentifier: normalizeIdentifier, getConfiguredPlayerIdentifier: getConfiguredPlayerIdentifier, fetchPlayerRegistrations: fetchPlayerRegistrations, resolvePlayerRegistration: resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistration, recordPlayerHeartbeat: recordPlayerHeartbeat };