'use strict'; // #150 — fingerprint-keyed device settings that survive device-row deletion. // // Delete + re-pair (Bold's MDM churn) mints a BRAND-NEW device row whose INSERT omits every // per-device setting, so orientation/name/playlist/etc silently reset to defaults. This module // snapshots a device's settings (keyed by its durable hardware/canvas fingerprint) at DELETE // time, and re-applies them on the next re-pair for the SAME fingerprint — automatically and // silently. The same apply path also backs the operator "re-adopt" action for the case where // the fingerprint changed (factory reset / new hardware), see routes/devices.js. // // The table has NO FK to devices, so device deletion can't cascade it away. On workspace/user/ // org deletion the rows ARE purged (purgeWorkspaces) so settings can never bleed across tenants. const { db } = require('../db/database'); const ORIENTATIONS = new Set(['landscape', 'portrait', 'landscape-flipped', 'portrait-flipped']); const validOrientation = (o) => (ORIENTATIONS.has(o) ? o : 'landscape'); // The devices-row columns we preserve/restore (approved scope: orientation, name, timezone, // notes, default_content_id, layout_id, playlist_id, blocked, team_id). sort_order is out of // scope by decision; wall membership (video_wall_devices grid geometry) is a deferred follow-up. const _selDevice = db.prepare( `SELECT name, orientation, timezone, notes, default_content_id, layout_id, playlist_id, blocked, team_id, workspace_id, last_heartbeat FROM devices WHERE id = ?` ); const _fpForDevice = db.prepare( 'SELECT fingerprint FROM device_fingerprints WHERE device_id = ? ORDER BY last_seen DESC LIMIT 1' ); const _upsert = db.prepare(` INSERT INTO device_settings (fingerprint, workspace_id, device_name, orientation, timezone, notes, default_content_id, layout_id, playlist_id, blocked, team_id, last_seen, removed_at) VALUES (@fingerprint, @workspace_id, @device_name, @orientation, @timezone, @notes, @default_content_id, @layout_id, @playlist_id, @blocked, @team_id, @last_seen, @removed_at) ON CONFLICT(fingerprint) DO UPDATE SET workspace_id=excluded.workspace_id, device_name=excluded.device_name, orientation=excluded.orientation, timezone=excluded.timezone, notes=excluded.notes, default_content_id=excluded.default_content_id, layout_id=excluded.layout_id, playlist_id=excluded.playlist_id, blocked=excluded.blocked, team_id=excluded.team_id, last_seen=excluded.last_seen, removed_at=excluded.removed_at `); // Snapshot a device's current settings keyed by its fingerprint, called BEFORE the row is // deleted. No-op (returns null) if the device has no fingerprint link yet — a never-fully- // provisioned device has no durable key and no user settings worth preserving. UPSERT keyed on // fingerprint => repeated delete/re-pair cycles update one row, never duplicate. function snapshot(deviceId, now = Math.floor(Date.now() / 1000)) { const d = _selDevice.get(deviceId); if (!d) return null; const fpRow = _fpForDevice.get(deviceId); if (!fpRow || !fpRow.fingerprint) return null; _upsert.run({ fingerprint: fpRow.fingerprint, workspace_id: d.workspace_id || null, device_name: d.name || null, orientation: validOrientation(d.orientation), timezone: d.timezone || null, notes: d.notes || null, default_content_id: d.default_content_id || null, layout_id: d.layout_id || null, playlist_id: d.playlist_id || null, blocked: d.blocked ? 1 : 0, team_id: d.team_id || null, last_seen: d.last_heartbeat || now, removed_at: now, }); return fpRow.fingerprint; } // Apply saved settings for `fingerprint` onto `deviceId`. Backs BOTH the automatic re-pair // restore and the operator re-adopt. Orientation is enum-validated (invalid stored value -> // landscape). FK settings (playlist/layout/default_content) are existence-guarded so a // since-deleted target is skipped rather than written as a dangling id. Returns the applied // snapshot row, or null if there was nothing to apply. function applyToDevice(deviceId, fingerprint) { const s = db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint); if (!s) return null; const sets = [], vals = []; const put = (col, val) => { sets.push(`${col} = ?`); vals.push(val); }; put('orientation', validOrientation(s.orientation)); if (s.device_name != null) put('name', s.device_name); if (s.timezone != null) put('timezone', s.timezone); if (s.notes != null) put('notes', s.notes); put('blocked', s.blocked ? 1 : 0); // security: a blocked device stays blocked across re-pair if (s.team_id != null) put('team_id', s.team_id); // FK-existence guards — only restore if the referenced row still exists. if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id)) put('playlist_id', s.playlist_id); if (s.layout_id && db.prepare('SELECT 1 FROM layouts WHERE id = ?').get(s.layout_id)) put('layout_id', s.layout_id); if (s.default_content_id && db.prepare('SELECT 1 FROM content WHERE id = ?').get(s.default_content_id)) put('default_content_id', s.default_content_id); // TODO #150 follow-up: wall membership (video_wall_devices grid geometry) is NOT restored — // it lives in a separate CASCADE-deleted table with grid positions. Deferred; note in release. vals.push(deviceId); db.prepare(`UPDATE devices SET ${sets.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...vals); return s; } // The "previously removed devices" browser — snapshots for the given workspace(s). function listRemoved(workspaceIds) { const ids = (Array.isArray(workspaceIds) ? workspaceIds : [workspaceIds]).filter(Boolean); if (!ids.length) return []; const ph = ids.map(() => '?').join(','); return db.prepare( `SELECT fingerprint, workspace_id, device_name, orientation, playlist_id, layout_id, timezone, blocked, last_seen, removed_at FROM device_settings WHERE workspace_id IN (${ph}) ORDER BY removed_at DESC` ).all(...ids); } function getByFingerprint(fingerprint) { return db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint); } // Purge snapshots for whole workspaces (workspace/user/org deletion). Runs on the caller's // db handle (user-deletion runs inside a transaction). Prevents cross-tenant settings bleed. function purgeWorkspaces(dbConn, workspaceIds) { const ids = (workspaceIds || []).filter(Boolean); if (!ids.length) return 0; const ph = ids.map(() => '?').join(','); return (dbConn || db).prepare(`DELETE FROM device_settings WHERE workspace_id IN (${ph})`).run(...ids).changes; } module.exports = { snapshot, applyToDevice, listRemoved, getByFingerprint, purgeWorkspaces, validOrientation, ORIENTATIONS };