diff --git a/.env.example b/.env.example index 071b85b..172aa19 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,15 @@ -NODE_ENV=production -WEB_PORT=3000 -PLAYER_PORT=3001 +PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest +PULSE_SIGNAGE_SHARED_SECRET=9f4c2d8b7a1e4c0f8b2d6a9e1c7f3b5d4a8e6c1f0b9d7a3c5e2f1a6b8d4c0e7 DB_HOST=mysql DB_PORT=3306 -DB_NAME=signage -DB_USER=signage_user +DB_NAME=pulse-signage +DB_USER=pulse-signage DB_PASSWORD=signage_password MYSQL_ROOT_PASSWORD=root_password -PLAYER_INTERNAL_BASE_URL=http://player:3001 -PLAYER_PUBLIC_BASE_URL=http://localhost:3001 -PULSE_SIGNAGE_SHARED_SECRET= +PLAYER_INTERNAL_BASE_URL=http://player:8081 +PLAYER_PUBLIC_BASE_URL=http://localhost:8081 SESSION_MAX_AGE_DAYS=14 DASHBOARD_REFRESH_INTERVAL_MS=2000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc07eb..2c9f62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,27 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 2.0.0 - 2026-07-28 -- No unreleased changes recorded yet. +### Breaking Changes + +- Breaking database schema changes were introduced in this release; existing databases may require migration or recreation before upgrading. + +### Added + +- Broader player and admin release coverage across onboarding, playlist playback, background task handling, and media-region support. + +### Changed + +- The database bootstrap and migration flow were reorganized, with shared DB helpers split out to support the newer schema layout. +- Admin and signage views were updated around the newer list, playlist, and RBAC behavior, including playlist scheduling and screen/client actions. +- Player runtime, onboarding, and region rendering were tightened so HTML, image, RSS, RTMP, text, video, and webpage regions follow the newer playback flow. +- Background task, upload sync, and data-source refresh handling were refined across the web app and startup paths. +- Docker, environment, README, and websocket/api documentation were refreshed to match the current release structure. + +### Fixed + +- Client name checks, playlist editing, and slide/media synchronization paths were adjusted to better preserve existing references during admin and player updates. ## 1.5.16 - 2026-07-26 diff --git a/README.md b/README.md index 0a5f3ae..1029083 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,6 @@ It runs as two connected services: Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately. -## Documentation - -Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands. - -Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts. - ## What You Need - Docker and Docker Compose @@ -47,35 +41,46 @@ You can change the initial admin credentials with these optional environment var ## Configuration -The app reads its settings from environment variables. +The app reads its settings from environment variables. The list below matches the provided `.env.example` file. + +### Compose and Shared Settings + +- `PULSE_SIGNAGE_IMAGE` - image tag used by the published Compose setup, default `git.lzstealth.com/lzstealth/pulse-signage:latest` +- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility ### Database Connection -- `DB_HOST` - MySQL host, default `127.0.0.1` +- `DB_HOST` - MySQL host, default `mysql` - `DB_PORT` - MySQL port, default `3306` -- `DB_NAME` - database name, default `signage` -- `DB_USER` - database user, default `signage_user` +- `DB_NAME` - database name, default `pulse-signage` +- `DB_USER` - database user, default `pulse-signage` - `DB_PASSWORD` - database password, default `signage_password` - -### Web Admin App - -- `WEB_PORT` - admin app port, default `3000` -- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001` -- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001` -- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility +- `MYSQL_ROOT_PASSWORD` - root password for the bundled MySQL container, default `root_password` ### Player App -- `PLAYER_PORT` - player port, default `3001` +- `PLAYER_INTERNAL_BASE_URL` - internal player URL used by the web app, default `http://player:8081` +- `PLAYER_PUBLIC_BASE_URL` - public player URL used by browser-facing links, default `http://localhost:8081` + +### Web App + +- `SESSION_MAX_AGE_DAYS` - admin session lifetime in days, default `14` +- `DASHBOARD_REFRESH_INTERVAL_MS` - dashboard refresh interval in milliseconds, default `2000` +- `DEFAULT_ADMIN_USERNAME` - username used for the initial admin account, default `admin` +- `DEFAULT_ADMIN_NAME` - display name used for the initial admin account, default `Admin` +- `DEFAULT_ADMIN_PASSWORD` - password used for the initial admin account, default `admin` +- `PASSWORD_HASH_ITERATIONS` - password hash iteration count, default `310000` ## Docker Compose The repository includes a `docker-compose.yml` file that starts three services: -- `web` - the admin app on port `3000` -- `player` - the signage player on port `3001` +- `web` - the admin app exposed on container port `8080` +- `player` - the signage player exposed on container port `8081` - `mysql` - the database on port `3306` +Change the published host ports in Docker Compose if you want different external ports; the app containers listen on `8080` and `8081` internally. + By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`. The Compose file also defines a shared `media` volume for stored assets and a `mysql_data` volume for database persistence. @@ -89,4 +94,12 @@ Outside Docker, the web app and player do not have to use the same upload locati - When running in Docker, that value should point to the Docker service name, not `localhost`. - If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path. - Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes. -- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock. \ No newline at end of file +- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock. + +## Documentation + +Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands. + +Database schema details live in [docs/schema.md](docs/schema.md). It summarizes the current tables created at startup. + +Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts. diff --git a/docker-compose.yml b/docker-compose.yml index 337da04..50ad355 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,8 @@ services: container_name: signage-web restart: unless-stopped ports: - - "3000:3000" + - "8080:8080" environment: - NODE_ENV: ${NODE_ENV:-production} - WEB_PORT: ${WEB_PORT:-3000} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} DB_NAME: ${DB_NAME:-signage} @@ -24,7 +22,7 @@ services: PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} volumes: - media:/app/media - command: ["npm", "run", "start:web"] + command: ["node", "src/web.js"] depends_on: mysql: condition: service_healthy @@ -36,11 +34,9 @@ services: container_name: signage-player restart: unless-stopped ports: - - "3001:3001" + - "8081:8081" environment: - NODE_ENV: ${NODE_ENV:-production} - PLAYER_PORT: ${PLAYER_PORT:-3001} - PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001} + PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} @@ -49,7 +45,7 @@ services: DB_PASSWORD: ${DB_PASSWORD:-signage_password} volumes: - media:/app/media - command: ["npm", "run", "start:player"] + command: ["node", "src/player.js"] depends_on: mysql: condition: service_healthy @@ -60,8 +56,6 @@ services: image: mysql:8.4 container_name: signage-mysql restart: unless-stopped - ports: - - "3306:3306" environment: MYSQL_DATABASE: ${DB_NAME:-signage} MYSQL_USER: ${DB_USER:-signage_user} diff --git a/docs/api.md b/docs/api.md index 4a7d0e8..711899b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,7 +2,7 @@ ## Overview -Player service base URL: `http://localhost:3001` +Player service base URL: `http://localhost:8081` This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management. diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..30e644c --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,176 @@ +# Database Schema + +This app creates and maintains its schema at startup through `src/db/index.js`. +The sections below summarize the current tables and their purpose. + +Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_at` and `modified_at` when a table supports auditing. + +## Admin + +- `a_users` - admin user accounts and password hashes. +- `a_roles` - named role definitions. +- `a_permissions` - permission catalog seeded from the application constants. +- `a_role_permissions` - many-to-many mapping between roles and permissions. +- `a_user_roles` - many-to-many mapping between users and roles. +- `a_sessions` - persisted admin session tokens. + +### `a_users` + +- `id`, `name`, `username`, `password_hash`, `password_salt`, `password_iterations`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `username` is unique. + +### `a_roles` + +- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `name` is unique. + +### `a_permissions` + +- `id`, `permission_key`, `name`, `section_name`, `description`, `created_at`, `modified_at` +- `permission_key` is unique. + +### `a_role_permissions` + +- `role_id`, `permission_id`, `created_at`, `modified_at` +- Foreign keys: + - `role_id` -> `a_roles.id` + - `permission_id` -> `a_permissions.id` +- Composite primary key: `(role_id, permission_id)` + +### `a_user_roles` + +- `user_id`, `role_id`, `created_at`, `modified_at` +- Foreign keys: + - `user_id` -> `a_users.id` + - `role_id` -> `a_roles.id` +- Composite primary key: `(user_id, role_id)` + +### `a_sessions` + +- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `session_hash` is the primary key. +- Foreign key: + - `user_id` -> `a_users.id` + +## Content + +- `c_canvas_sizes` - reusable canvas presets for templates. +- `c_playlists` - playlist definitions and playback options. +- `c_templates` - slide templates with canvas and background settings. +- `c_template_regions` - template region layout and metadata. +- `c_slides` - slide records with template binding, JSON content, and thumbnail path. +- `c_playlist_slides` - ordered playlist items, timing, and schedule rules. + +### `c_canvas_sizes` + +- `id`, `name`, `width`, `height`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `(width, height)` is unique. + +### `c_playlists` + +- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `created_at`, `created_by`, `modified_at`, `modified_by` + +### `c_templates` + +- `id`, `name`, `canvas_size_id`, `background_image_path`, `background_color`, `created_at`, `created_by`, `modified_at`, `modified_by` +- Foreign key: + - `canvas_size_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL` + +### `c_template_regions` + +- `id`, `template_id`, `region_key`, `region_type`, `label`, `lock_ratio`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by` +- Foreign key: + - `template_id` -> `c_templates.id` with `ON DELETE CASCADE` + +### `c_slides` + +- `id`, `title`, `template_id`, `content_json`, `thumbnail_path`, `created_at`, `created_by`, `modified_at`, `modified_by` +- Foreign key: + - `template_id` -> `c_templates.id` with `ON DELETE SET NULL` + +### `c_playlist_slides` + +- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `schedule_mode`, `schedule_start_datetime`, `schedule_end_datetime`, `schedule_start_time`, `schedule_end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by` +- Foreign keys: + - `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE` + - `slide_id` -> `c_slides.id` with `ON DELETE CASCADE` + +### `d_screens` + +- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `slug` is unique. +- Foreign keys: + - `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL` + - `player_id` -> `d_players.id` with `ON DELETE SET NULL` + +## Onboarding + +- `d_onboarding_devices` - device-to-screen bindings and onboarded client names. + +### `d_onboarding_devices` + +- `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `device_id` is the primary key. +- Foreign key: + - `screen_id` -> `d_screens.id` with `ON DELETE SET NULL` + +## Integrations + +- `i_rss_feeds` - RSS feed definitions and refresh cadence. +- `i_rss_feed_items` - cached RSS feed items. +- `i_api_sources` - API source definitions and last response snapshot. + +### `i_rss_feeds` + +- `id`, `name`, `feed_url`, `update_interval_value`, `update_interval_unit`, `item_limit`, `created_at`, `created_by`, `modified_at`, `modified_by` + +### `i_rss_feed_items` + +- `id`, `rss_feed_id`, `position`, `item_json`, `created_at`, `created_by`, `modified_at`, `modified_by` +- Foreign key: + - `rss_feed_id` -> `i_rss_feeds.id` with `ON DELETE CASCADE` +- Unique key: + - `(rss_feed_id, position)` + +### `i_api_sources` + +- `id`, `name`, `api_url`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by` + +## Operations + +- `o_background_tasks` - queue and history for background jobs. + +### `o_background_tasks` + +- `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message` +- Indexed by `status`, `task_key`, and `task_type`. + +## Notes + +- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database. +- `src/db/index.js` also seeds default permissions and the default administrator role. +- The migration module stays in place for future releases, but this version treats the current schema as the install baseline. + +```mermaid +erDiagram + A_USERS ||--o{ A_USER_ROLES : has + A_ROLES ||--o{ A_USER_ROLES : assigned_to + A_ROLES ||--o{ A_ROLE_PERMISSIONS : has + A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to + A_USERS ||--o{ A_SESSIONS : owns + + C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by + C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains + C_TEMPLATES ||--o{ C_SLIDES : used_by + C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains + C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in + + D_PLAYERS ||--o{ D_SCREENS : assigned_to + C_PLAYLISTS ||--o{ D_SCREENS : uses + D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds + + I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches + O_BACKGROUND_TASKS { + BIGINT id + } +``` \ No newline at end of file diff --git a/docs/websocket.md b/docs/websocket.md index ad36188..4d0bb07 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -2,7 +2,7 @@ ## Overview -Player service websocket base URL: `ws://localhost:3001` +Player service websocket base URL: `ws://localhost:8081` This document uses OpenAPI-style sections, but stays in plain markdown. @@ -28,29 +28,7 @@ Access: internal-only. The web backend subscribes with signed request headers; b ### Messages from player to server -The player sends two message types: - -- `hello` -- `state` - -#### `hello` - -Example payload: - -```json -{ - "type": "hello", - "clientId": "client-id", - "clientName": "friendly label", - "deviceId": "device-id", - "userAgent": "browser ua", - "page": "http://.../screen/demo", - "viewport": { "width": 1920, "height": 1080 }, - "paused": false, - "blackout": false, - "currentSlide": null -} -``` +The player sends a state snapshot message. #### `state` @@ -67,16 +45,13 @@ Example payload: "viewport": { "width": 1920, "height": 1080 }, "paused": false, "blackout": false, - "currentSlide": { - "id": 12, - "title": "Main Slide", - "kind": "image", - "playlistSignature": "..." - } + "currentSlide": null } ``` -The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from player messages. When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature. +When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature. + +The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from state messages. ### Messages from server to player @@ -92,15 +67,13 @@ The server sends command messages with: Supported commands: +- `blackout` +- `next` +- `pause` +- `previous` - `refresh` - `reload` - `redirect` -- `pause` -- `blackout` -- `previous` -- `next` -- `left` -- `right` - `setclientname` #### `refresh` @@ -130,10 +103,10 @@ Use `false` to restore and `true` to blackout. #### `redirect` Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command. -#### `previous` / `left` +#### `previous` Moves to the previous slide. -#### `next` / `right` +#### `next` Moves to the next slide. #### `setclientname` diff --git a/package-lock.json b/package-lock.json index c20c91d..e17e279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "1.5.16", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "1.5.16", + "version": "2.0.0", "dependencies": { "@sparticuz/chromium": "^137.0.0", "bootstrap-icons": "1.11.3", diff --git a/package.json b/package.json index 8b48f9e..b19c4f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.5.16", + "version": "2.0.0", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/common.js b/src/common.js index 4e6af73..9c2af50 100644 --- a/src/common.js +++ b/src/common.js @@ -1,4 +1,6 @@ +const dbCommon = require('./db/common'); const db = require('./db'); +const dbBootstrap = require('./db/bootstrap'); const data = require('./data'); const player = require('./player/render'); @@ -15,8 +17,10 @@ function getSortDirectionQuery(req) { } module.exports = { - createPool: db.createPool, + createPool: dbCommon.createPool, + pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices, ensureSchema: db.ensureSchema, + bootstrapDatabase: dbBootstrap.bootstrapDatabase, slugify: data.slugify, uniqueScreenSlug: data.uniqueScreenSlug, parseJsonSafe: data.parseJsonSafe, diff --git a/src/data/admin.js b/src/data/admin.js index 440f3fb..422e10e 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -1,35 +1,36 @@ const { fetchPagedRows } = require('./utils'); async function fetchAdminData(pool) { - const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); - const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC'); + const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC'); + const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC'); const [templates] = await pool.query(` SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height - FROM slide_templates st - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + FROM c_templates st + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY st.id DESC `); - const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); + const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); const [slides] = await pool.query(` - SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height - FROM slides s - LEFT JOIN slide_templates st ON st.id = s.template_id - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height, + (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count + FROM c_slides s + LEFT JOIN c_templates st ON st.id = s.template_id + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY s.id DESC `); const [screens] = await pool.query(` SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name - FROM screens s - LEFT JOIN playlists p ON p.id = s.playlist_id + FROM d_screens s + LEFT JOIN c_playlists p ON p.id = s.playlist_id ORDER BY s.id DESC `); const [playlistSlides] = await pool.query(` - SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height - FROM playlist_slides ps - JOIN slides sl ON sl.id = ps.slide_id - LEFT JOIN slide_templates st ON st.id = sl.template_id - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height + 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 ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC `); return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides }; @@ -37,11 +38,11 @@ async function fetchAdminData(pool) { async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by, - (SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count - FROM playlists p + selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by, + (SELECT COUNT(*) FROM c_playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count + FROM c_playlists p ORDER BY p.id DESC`, - countSql: 'SELECT COUNT(*) AS count FROM playlists', + countSql: 'SELECT COUNT(*) AS count FROM c_playlists', searchColumns: ['p.name'], searchTerm: searchTerm, sortColumns: { @@ -61,13 +62,14 @@ async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sor async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height - FROM slides s - LEFT JOIN slide_templates st ON st.id = s.template_id - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + selectSql: `SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height, + (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count + FROM c_slides s + LEFT JOIN c_templates st ON st.id = s.template_id + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY s.id DESC`, - countSql: 'SELECT COUNT(*) AS count FROM slides', - searchColumns: ['s.title', 's.body', 's.media_path', 'st.name', 's.content_json'], + countSql: 'SELECT COUNT(*) AS count FROM c_slides', + searchColumns: ['s.title', 'st.name', 's.content_json'], searchTerm: searchTerm, sortColumns: { title: 's.title', @@ -88,12 +90,12 @@ async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sor const paged = await fetchPagedRows(pool, { selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, - (SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count, - (SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count - FROM slide_templates st - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + (SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count, + (SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count + FROM c_templates st + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY st.id DESC`, - countSql: 'SELECT COUNT(*) AS count FROM slide_templates', + countSql: 'SELECT COUNT(*) AS count FROM c_templates', searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'], searchTerm: searchTerm, sortColumns: { @@ -116,10 +118,10 @@ async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sor async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by, - (SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count - FROM canvas_sizes + (SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count + FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC`, - countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', + countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes', searchColumns: ['name', 'width', 'height'], searchTerm: searchTerm, sortColumns: { @@ -141,10 +143,10 @@ async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, s async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name - FROM screens s - LEFT JOIN playlists p ON p.id = s.playlist_id + FROM d_screens s + LEFT JOIN c_playlists p ON p.id = s.playlist_id ORDER BY s.id DESC`, - countSql: 'SELECT COUNT(*) AS count FROM screens', + countSql: 'SELECT COUNT(*) AS count FROM d_screens', searchColumns: ['s.name', 's.slug', 'p.name'], searchTerm: searchTerm, sortColumns: { diff --git a/src/data/api-sources.js b/src/data/api-sources.js index 6e7ea65..747a9fe 100644 --- a/src/data/api-sources.js +++ b/src/data/api-sources.js @@ -9,7 +9,7 @@ function normalizeUpdateIntervalUnit(value) { async function fetchApiSourcesData(pool) { const [apiSources] = await pool.query( - 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC' + 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC' ); return { apiSources: apiSources }; @@ -17,8 +17,8 @@ async function fetchApiSourcesData(pool) { async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC', - countSql: 'SELECT COUNT(*) AS count FROM api_sources', + selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC', + countSql: 'SELECT COUNT(*) AS count FROM i_api_sources', searchColumns: ['name', 'api_url', 'last_pull_error'], searchTerm: searchTerm, sortColumns: { @@ -41,7 +41,7 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so async function fetchApiSourceById(pool, id) { const [rows] = await pool.query( - 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources WHERE id = ?', + 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?', [id] ); diff --git a/src/data/canvas-sizes.js b/src/data/canvas-sizes.js index 6d0ee4a..d40845b 100644 --- a/src/data/canvas-sizes.js +++ b/src/data/canvas-sizes.js @@ -1,14 +1,14 @@ const { fetchPagedRows } = require('./utils'); async function fetchCanvasSizesData(pool) { - const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC'); + const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC'); return { canvasSizes }; } async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC', - countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', + selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC', + countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes', searchColumns: ['name', 'width', 'height'], searchTerm: searchTerm, sortColumns: { @@ -27,7 +27,7 @@ async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, s } async function fetchCanvasSizeById(pool, id) { - const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]); + const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]); return rows[0] || null; } diff --git a/src/data/client-name-check.js b/src/data/client-name-check.js index 11ea3aa..40f0634 100644 --- a/src/data/client-name-check.js +++ b/src/data/client-name-check.js @@ -26,7 +26,7 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn if (pool) { const [deviceRows] = await pool.query( `SELECT device_id - FROM player_onboarding_devices + FROM d_onboarding_devices WHERE client_name IS NOT NULL AND TRIM(client_name) <> '' AND LOWER(TRIM(client_name)) = LOWER(TRIM(?)) diff --git a/src/data/playlists.js b/src/data/playlists.js index 88a7e79..b187862 100644 --- a/src/data/playlists.js +++ b/src/data/playlists.js @@ -1,5 +1,5 @@ async function fetchPlaylistById(pool, id) { - const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]); + const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]); return rows[0] || null; } diff --git a/src/data/rss-feeds.js b/src/data/rss-feeds.js index 5a24df2..a1e2f4c 100644 --- a/src/data/rss-feeds.js +++ b/src/data/rss-feeds.js @@ -9,7 +9,7 @@ function normalizeUpdateIntervalUnit(value) { async function fetchRssFeedsData(pool) { const [rssFeeds] = await pool.query( - 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC' + 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC' ); return { rssFeeds: rssFeeds }; @@ -17,8 +17,8 @@ async function fetchRssFeedsData(pool) { async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC', - countSql: 'SELECT COUNT(*) AS count FROM rss_feeds', + selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC', + countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds', searchColumns: ['name', 'feed_url'], searchTerm: searchTerm, sortColumns: { @@ -40,7 +40,7 @@ async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sort async function fetchRssFeedById(pool, id) { const [rows] = await pool.query( - 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?', + 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?', [id] ); diff --git a/src/data/screens.js b/src/data/screens.js index 28f4ce1..e632e2e 100644 --- a/src/data/screens.js +++ b/src/data/screens.js @@ -13,7 +13,7 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) { let counter = 2; while (true) { const params = [candidate]; - let sql = 'SELECT id FROM screens WHERE slug = ?'; + let sql = 'SELECT id FROM d_screens WHERE slug = ?'; if (excludeId !== undefined && excludeId !== null) { sql += ' AND id <> ?'; params.push(excludeId); @@ -30,15 +30,15 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) { async function fetchScreenById(pool, id) { const [rows] = await pool.query(` SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name - FROM screens s - LEFT JOIN playlists p ON p.id = s.playlist_id + FROM d_screens s + LEFT JOIN c_playlists p ON p.id = s.playlist_id WHERE s.id = ? `, [id]); return rows[0] || null; } async function fetchScreenEditData(pool) { - const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); + const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC'); return { playlists }; } diff --git a/src/data/slides.js b/src/data/slides.js index e09f37e..0c28088 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -27,11 +27,11 @@ function sanitizeRichText(html) { async function fetchSlideById(pool, id) { const [slides] = await pool.query(` - SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, + SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height - FROM slides s - LEFT JOIN slide_templates st ON st.id = s.template_id - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + FROM c_slides s + LEFT JOIN c_templates st ON st.id = s.template_id + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id WHERE s.id = ? `, [id]); if (!slides.length) { @@ -198,21 +198,15 @@ async function buildSlidePayload(pool, req, existingSlide) { if (template) { return { title, - body: existingSlide ? existingSlide.body : null, templateId: template.id, - contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)), - mediaPath: null, - mediaType: null + contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)) }; } return { title, - body: existingSlide ? existingSlide.body : null, templateId: null, - contentJson: existingSlide ? existingSlide.content_json : null, - mediaPath: existingSlide ? existingSlide.media_path : null, - mediaType: existingSlide ? existingSlide.media_type : null + contentJson: existingSlide ? existingSlide.content_json : null }; } diff --git a/src/data/templates.js b/src/data/templates.js index 05b94dc..c4e5d72 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -50,15 +50,15 @@ async function fetchTemplateById(pool, id) { const [templates] = await pool.query(` SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height - FROM slide_templates st - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + FROM c_templates st + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id WHERE st.id = ? `, [id]); if (!templates.length) { return null; } const template = templates[0]; - const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]); + const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]); template.regions = regions; return template; } @@ -67,11 +67,11 @@ async function fetchTemplatesData(pool) { const [templates] = await pool.query(` SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height - FROM slide_templates st - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + FROM c_templates st + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY st.id DESC `); - const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); + const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); return { templates, templateRegions }; } @@ -167,7 +167,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) { let resolvedCanvasSizeId = canvasSizeId; if (resolvedCanvasSizeId) { - const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]); + const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]); const canvasSize = canvasSizes[0]; if (!canvasSize) { const error = new Error('Canvas size not found.'); diff --git a/src/db/bootstrap.js b/src/db/bootstrap.js new file mode 100644 index 0000000..6afb8b0 --- /dev/null +++ b/src/db/bootstrap.js @@ -0,0 +1,72 @@ +const { hashPassword } = require('../auth'); +const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac'); + +async function bootstrapDatabase(pool) { + for (const permission of PERMISSIONS) { + await pool.query( + `INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` , + [permission.key, permission.name, permission.sectionName, permission.description || null, null, null] + ); + } + + const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users'); + const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin'; + if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) { + 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 IGNORE INTO a_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 a_users SET name = username WHERE name IS NULL OR name = ""'); + + await pool.query( + `INSERT INTO a_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] + ); + + const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]); + const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null; + if (defaultRoleId) { + await pool.query( + `INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) + SELECT ?, id, NULL, NULL FROM a_permissions`, + [defaultRoleId] + ); + } + + if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) { + if (defaultRoleId) { + await pool.query( + `INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) + SELECT id, ?, NULL, NULL FROM a_users`, + [defaultRoleId] + ); + } + } + + const [defaultAdminRows] = await pool.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]); + if (defaultAdminRows.length) { + const defaultAdminId = Number(defaultAdminRows[0].id); + const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM a_user_roles WHERE user_id = ?', [defaultAdminId]); + if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) { + if (defaultRoleId) { + await pool.query( + 'INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', + [defaultAdminId, defaultRoleId, null, null] + ); + } + } + } +} + +module.exports = { + bootstrapDatabase +}; \ No newline at end of file diff --git a/src/db/common.js b/src/db/common.js new file mode 100644 index 0000000..f1b25ea --- /dev/null +++ b/src/db/common.js @@ -0,0 +1,26 @@ +const mysql = require('mysql2/promise'); + +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 pruneStaleOnboardingDevices(pool) { + await pool.query( + `DELETE FROM d_onboarding_devices + WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)` + ); +} + +module.exports = { + createPool, + pruneStaleOnboardingDevices +}; \ No newline at end of file diff --git a/src/db/index.js b/src/db/index.js index b384e56..b3aca5f 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -1,31 +1,6 @@ -const mysql = require('mysql2/promise'); -const { hashPassword } = require('../auth'); -const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac'); -const migrations = require('./migrations'); - -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 pruneStaleOnboardingDevices(pool) { - await pool.query( - `DELETE FROM player_onboarding_devices - WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)` - ); -} - async function ensureSchema(pool, options) { await pool.query(` - CREATE TABLE IF NOT EXISTS canvas_sizes ( + CREATE TABLE IF NOT EXISTS c_canvas_sizes ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, width INT NOT NULL, @@ -39,7 +14,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS playlists ( + CREATE TABLE IF NOT EXISTS c_playlists ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, fade_between_slides TINYINT(1) NOT NULL DEFAULT 0, @@ -52,7 +27,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS slide_templates ( + CREATE TABLE IF NOT EXISTS c_templates ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, canvas_size_id INT NULL, @@ -66,7 +41,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES + INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES ('Full HD', 1920, 1080), ('HD', 1280, 720), ('4K UHD', 3840, 2160), @@ -75,12 +50,13 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS slide_template_regions ( + CREATE TABLE IF NOT EXISTS c_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, + font_family VARCHAR(100) NULL, lock_ratio VARCHAR(20) NULL, x INT NOT NULL DEFAULT 0, y INT NOT NULL DEFAULT 0, @@ -95,14 +71,11 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS slides ( + CREATE TABLE IF NOT EXISTS c_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, thumbnail_path VARCHAR(512) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, @@ -112,7 +85,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS playlist_slides ( + CREATE TABLE IF NOT EXISTS c_playlist_slides ( id INT AUTO_INCREMENT PRIMARY KEY, playlist_id INT NOT NULL, slide_id INT NOT NULL, @@ -129,27 +102,28 @@ async function ensureSchema(pool, options) { created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, - 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 + CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE, + CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query(` - CREATE TABLE IF NOT EXISTS screens ( + CREATE TABLE IF NOT EXISTS d_screens ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, playlist_id INT NULL, + player_id INT 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_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL + CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query(` - CREATE TABLE IF NOT EXISTS rss_feeds ( + CREATE TABLE IF NOT EXISTS i_rss_feeds ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, feed_url VARCHAR(1024) NOT NULL, @@ -164,7 +138,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS rss_feed_items ( + CREATE TABLE IF NOT EXISTS i_rss_feed_items ( id INT AUTO_INCREMENT PRIMARY KEY, rss_feed_id INT NOT NULL, position INT NOT NULL, @@ -173,13 +147,13 @@ async function ensureSchema(pool, options) { created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, - CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE, + CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES i_rss_feeds(id) ON DELETE CASCADE, UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query(` - CREATE TABLE IF NOT EXISTS api_sources ( + CREATE TABLE IF NOT EXISTS i_api_sources ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, api_url VARCHAR(1024) NOT NULL, @@ -198,7 +172,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS player_onboarding_devices ( + CREATE TABLE IF NOT EXISTS d_onboarding_devices ( device_id VARCHAR(128) PRIMARY KEY, client_name VARCHAR(255) NULL, screen_id INT NULL, @@ -206,12 +180,12 @@ async function ensureSchema(pool, options) { created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, - CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL + CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query(` - CREATE TABLE IF NOT EXISTS users ( + CREATE TABLE IF NOT EXISTS a_users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NULL, username VARCHAR(255) NOT NULL UNIQUE, @@ -226,7 +200,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS roles ( + CREATE TABLE IF NOT EXISTS a_roles ( id INT AUTO_INCREMENT PRIMARY KEY, role_key VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, @@ -239,7 +213,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS permissions ( + CREATE TABLE IF NOT EXISTS a_permissions ( id INT AUTO_INCREMENT PRIMARY KEY, permission_key VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, @@ -252,17 +226,8 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - for (const permission of PERMISSIONS) { - await pool.query( - `INSERT INTO permissions (permission_key, name, section_name, description, created_by, modified_by) - VALUES (?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` , - [permission.key, permission.name, permission.sectionName, permission.description || null, null, null] - ); - } - await pool.query(` - CREATE TABLE IF NOT EXISTS role_permissions ( + CREATE TABLE IF NOT EXISTS a_role_permissions ( role_id INT NOT NULL, permission_id INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -270,13 +235,13 @@ async function ensureSchema(pool, options) { modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, 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 + CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE, + CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES a_permissions(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query(` - CREATE TABLE IF NOT EXISTS user_roles ( + CREATE TABLE IF NOT EXISTS a_user_roles ( user_id INT NOT NULL, role_id INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -284,13 +249,13 @@ async function ensureSchema(pool, options) { modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, 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 + CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query(` - CREATE TABLE IF NOT EXISTS auth_sessions ( + CREATE TABLE IF NOT EXISTS a_sessions ( session_hash CHAR(64) PRIMARY KEY, user_id INT NOT NULL, expires_at DATETIME NOT NULL, @@ -298,12 +263,12 @@ async function ensureSchema(pool, options) { created_by INT NULL, last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, - CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); await pool.query(` - CREATE TABLE IF NOT EXISTS background_tasks ( + CREATE TABLE IF NOT EXISTS o_background_tasks ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_key VARCHAR(191) NULL, task_type VARCHAR(100) NOT NULL, @@ -322,61 +287,8 @@ async function ensureSchema(pool, options) { INDEX idx_background_tasks_type (task_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - - const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users'); - const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin'; - if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) { - 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 migrations.runMigrations(pool, options || {}); - - 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] - ); - } - } - - const [defaultAdminRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]); - if (defaultAdminRows.length) { - const defaultAdminId = Number(defaultAdminRows[0].id); - const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM user_roles WHERE user_id = ?', [defaultAdminId]); - if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_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) VALUES (?, ?, ?, ?)', - [defaultAdminId, defaultRoleId, null, null] - ); - } - } - } } module.exports = { - createPool, ensureSchema, - pruneStaleOnboardingDevices }; diff --git a/src/db/migrations.js b/src/db/migrations.js index 4f23bbc..e5c81a5 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -1,848 +1,10 @@ -const fs = require('fs'); -const path = require('path'); const { version: appVersion } = require('../../package.json'); -function parseVersion(value) { - const parts = String(value || '0.0.0').split('.').map(function (part) { - return Math.max(0, Number(part) || 0); - }); - - return { - major: parts[0] || 0, - minor: parts[1] || 0, - patch: parts[2] || 0 - }; -} - -function compareVersions(left, right) { - const leftVersion = parseVersion(left); - const rightVersion = parseVersion(right); - - if (leftVersion.major !== rightVersion.major) { - return leftVersion.major - rightVersion.major; - } - if (leftVersion.minor !== rightVersion.minor) { - return leftVersion.minor - rightVersion.minor; - } - if (leftVersion.patch !== rightVersion.patch) { - return leftVersion.patch - rightVersion.patch; - } - - return 0; -} - -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 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 = String((row && row.permission_key) || '').trim().toLowerCase(); - 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 addAuditColumns(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` - ); - 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` - ); - - 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 hasColumn(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 rows.length && Number(rows[0].column_count) > 0; -} - -async function backfillLegacyRssFeedItemJson(pool) { - const [rows] = await pool.query( - `SELECT column_name - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'rss_feed_items'` - ); - const columnNames = new Set((rows || []).map(function (row) { - return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase(); - }).filter(Boolean)); - - if (!['title', 'link', 'pub_date', 'description'].every(function (columnName) { - return columnNames.has(columnName); - })) { - return; - } - - await pool.query( - `UPDATE rss_feed_items - SET item_json = JSON_OBJECT( - 'title', title, - 'link', link, - 'pubDate', pub_date, - 'description', description - ) - WHERE item_json IS NULL` - ); -} - -async function backfillLegacySlideTemplateCanvasSize(pool) { - 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) { - return; - } - - 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 - `); -} - -function replaceUploadPrefixInValue(value) { - if (typeof value === 'string') { - return value.replace(/\/uploads\//g, '/media/'); - } - if (Array.isArray(value)) { - return value.map(function (item) { - return replaceUploadPrefixInValue(item); - }); - } - if (value && typeof value === 'object') { - return Object.keys(value).reduce(function (result, key) { - result[key] = replaceUploadPrefixInValue(value[key]); - return result; - }, {}); - } - return value; -} - -function replaceLegacyMediaUploadPathInValue(value) { - if (typeof value === 'string') { - if (!value.startsWith('/media/') || value.startsWith('/media/uploads/')) { - return value; - } - return '/media/uploads/' + path.basename(value); - } - if (Array.isArray(value)) { - return value.map(function (item) { - return replaceLegacyMediaUploadPathInValue(item); - }); - } - if (value && typeof value === 'object') { - return Object.keys(value).reduce(function (result, key) { - result[key] = replaceLegacyMediaUploadPathInValue(value[key]); - return result; - }, {}); - } - return value; -} - -function parseJsonValue(value) { - if (value === null || value === undefined || value === '') { - return null; - } - if (typeof value !== 'string') { - return value; - } - try { - return JSON.parse(value); - } catch (_error) { - return value; - } -} - -async function backfillLegacyMediaPaths(pool) { - const [slides] = await pool.query(` - SELECT id, media_path, content_json - FROM slides - WHERE media_path LIKE '/uploads/%' - OR content_json LIKE '%/uploads/%' - `); - - for (const slide of slides || []) { - let mediaPath = String(slide.media_path || '').trim() || null; - let contentJson = slide.content_json; - let changed = false; - - if (mediaPath && mediaPath.startsWith('/uploads/')) { - mediaPath = mediaPath.replace(/^\/uploads\//, '/media/'); - changed = true; - } - - const parsedContent = parseJsonValue(contentJson); - if (parsedContent && typeof parsedContent === 'object') { - const updatedContent = replaceUploadPrefixInValue(parsedContent); - if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) { - contentJson = JSON.stringify(updatedContent); - changed = true; - } - } else if (typeof parsedContent === 'string' && parsedContent.indexOf('/uploads/') !== -1) { - contentJson = parsedContent.replace(/\/uploads\//g, '/media/'); - changed = true; - } - - if (changed) { - await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]); - } - } - - const [templates] = await pool.query(` - SELECT id, background_image_path - FROM slide_templates - WHERE background_image_path LIKE '/uploads/%' - `); - - for (const template of templates || []) { - const backgroundImagePath = String(template.background_image_path || '').trim(); - if (!backgroundImagePath.startsWith('/uploads/')) { - continue; - } - await pool.query( - 'UPDATE slide_templates SET background_image_path = ? WHERE id = ?', - [backgroundImagePath.replace(/^\/uploads\//, '/media/'), template.id] - ); - } -} - -async function moveFileIfMissing(sourcePath, targetPath) { - try { - await fs.promises.access(targetPath, fs.constants.F_OK); - return false; - } catch (_error) { - // target does not exist - } - - try { - await fs.promises.access(sourcePath, fs.constants.F_OK); - } catch (_error) { - return false; - } - - await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); - - try { - await fs.promises.rename(sourcePath, targetPath); - } catch (error) { - if (error && error.code === 'EXDEV') { - await fs.promises.copyFile(sourcePath, targetPath); - await fs.promises.unlink(sourcePath); - return true; - } - throw error; - } - - return true; -} - -async function backfillLegacyMediaUploadsToSubfolder(pool, mediaDir) { - const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null; - if (!normalizedMediaDir) { - return; - } - - const uploadsDir = path.join(normalizedMediaDir, 'uploads'); - await fs.promises.mkdir(uploadsDir, { recursive: true }); - - const [slides] = await pool.query(` - SELECT id, media_path, content_json - FROM slides - WHERE media_path LIKE '/media/%' - OR content_json LIKE '%/media/%' - `); - - for (const slide of slides || []) { - let mediaPath = String(slide.media_path || '').trim() || null; - let contentJson = slide.content_json; - let changed = false; - - if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) { - const fileName = path.basename(mediaPath); - await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName)); - mediaPath = '/media/uploads/' + fileName; - changed = true; - } - - const parsedContent = parseJsonValue(contentJson); - if (parsedContent && typeof parsedContent === 'object') { - const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent); - if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) { - contentJson = JSON.stringify(updatedContent); - changed = true; - } - } else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) { - const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent); - if (updatedContent !== parsedContent) { - contentJson = updatedContent; - changed = true; - } - } - - if (changed) { - await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]); - } - } - - const [templates] = await pool.query(` - SELECT id, background_image_path - FROM slide_templates - WHERE background_image_path LIKE '/media/%' - `); - - for (const template of templates || []) { - const backgroundImagePath = String(template.background_image_path || '').trim(); - if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) { - continue; - } - - const fileName = path.basename(backgroundImagePath); - await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName)); - await pool.query( - 'UPDATE slide_templates SET background_image_path = ? WHERE id = ?', - ['/media/uploads/' + fileName, template.id] - ); - } -} - -async function backfillLooseMediaFilesToUploadsSubfolder(pool, mediaDir) { - const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null; - if (!normalizedMediaDir) { - return; - } - - const uploadsDir = path.join(normalizedMediaDir, 'uploads'); - await fs.promises.mkdir(uploadsDir, { recursive: true }); - - const directoryEntries = await fs.promises.readdir(normalizedMediaDir, { withFileTypes: true }); - for (const entry of directoryEntries || []) { - if (!entry || !entry.isFile()) { - continue; - } - - const fileName = String(entry.name || '').trim(); - if (!fileName) { - continue; - } - - const sourcePath = path.join(normalizedMediaDir, fileName); - const targetPath = path.join(uploadsDir, fileName); - await moveFileIfMissing(sourcePath, targetPath); - } - - const [slides] = await pool.query(` - SELECT id, media_path, content_json - FROM slides - WHERE media_path LIKE '/media/%' - OR content_json LIKE '%/media/%' - `); - - for (const slide of slides || []) { - let mediaPath = String(slide.media_path || '').trim() || null; - let contentJson = slide.content_json; - let changed = false; - - if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) { - mediaPath = '/media/uploads/' + path.basename(mediaPath); - changed = true; - } - - const parsedContent = parseJsonValue(contentJson); - if (parsedContent && typeof parsedContent === 'object') { - const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent); - if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) { - contentJson = JSON.stringify(updatedContent); - changed = true; - } - } else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) { - const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent); - if (updatedContent !== parsedContent) { - contentJson = updatedContent; - changed = true; - } - } - - if (changed) { - await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]); - } - } - - const [templates] = await pool.query(` - SELECT id, background_image_path - FROM slide_templates - WHERE background_image_path LIKE '/media/%' - `); - - for (const template of templates || []) { - const backgroundImagePath = String(template.background_image_path || '').trim(); - if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) { - continue; - } - - const fileName = path.basename(backgroundImagePath); - await pool.query( - 'UPDATE slide_templates SET background_image_path = ? WHERE id = ?', - ['/media/uploads/' + fileName, template.id] - ); - } -} - -async function ensureMigrationTable(pool) { - await pool.query(` - CREATE TABLE IF NOT EXISTS schema_migrations ( - id INT AUTO_INCREMENT PRIMARY KEY, - migration_key VARCHAR(100) NOT NULL UNIQUE, - app_version VARCHAR(32) NOT NULL, - comment TEXT NOT NULL, - applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); -} - -async function getAppliedMigrationRows(pool) { - await ensureMigrationTable(pool); - - const [rows] = await pool.query( - 'SELECT migration_key, app_version, comment, applied_at FROM schema_migrations ORDER BY id ASC' - ); - - return rows || []; -} - -function getLatestAppliedVersion(rows) { - let latestVersion = '0.0.0'; - - for (const row of rows || []) { - const candidateVersion = String(row.app_version || '0.0.0'); - if (compareVersions(candidateVersion, latestVersion) > 0) { - latestVersion = candidateVersion; - } - } - - return latestVersion; -} - -async function recordMigration(pool, migration) { - await pool.query( - 'INSERT IGNORE INTO schema_migrations (migration_key, app_version, comment) VALUES (?, ?, ?)', - [migration.key, migration.version, migration.comment] - ); -} - -const migrations = [ - { - key: 'interval-value-rename', - version: appVersion, - comment: 'Rename RSS and API refresh interval columns to update_interval_value so seconds and minutes share one neutral numeric field.', - order: 10, - up: async function (pool) { - await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60'); - await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'"); - await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60'); - await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'"); - - if (await hasColumn(pool, 'rss_feeds', 'update_interval_minutes')) { - await pool.query(` - UPDATE rss_feeds - SET update_interval_value = update_interval_minutes - `); - await dropColumnIfPresent(pool, 'rss_feeds', 'update_interval_minutes'); - } - - if (await hasColumn(pool, 'api_sources', 'update_interval_minutes')) { - await pool.query(` - UPDATE api_sources - SET update_interval_value = update_interval_minutes - `); - await dropColumnIfPresent(pool, 'api_sources', 'update_interval_minutes'); - } - } - }, - { - key: 'schema-columns-current', - version: appVersion, - comment: 'Backfill the released schema columns for older databases.', - order: 20, - up: async function (pool) { - // Current released schema: keep canvas_sizes audit fields available in older databases. - await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await addAuditColumns(pool, 'canvas_sizes'); - - // Current released schema: ensure playlists carry the playback and audit fields. - await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0'); - await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0'); - await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await addAuditColumns(pool, 'playlists'); - - // Current released schema: keep slide template canvas and background fields available. - 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 addAuditColumns(pool, 'slide_templates'); - await backfillLegacySlideTemplateCanvasSize(pool); - - // Current released schema: keep slide template region typography and audit fields available. - await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL'); - await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL'); - await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await addAuditColumns(pool, 'slide_template_regions'); - - // Current released schema: keep structured slide content and media fields available. - 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', 'thumbnail_path', 'VARCHAR(512) NULL'); - await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await addAuditColumns(pool, 'slides'); - - // Current released schema: keep playlist slide duration, schedule, and audit fields available. - await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000'); - 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 addAuditColumns(pool, 'playlist_slides'); - - // Current released schema: keep screen playlist bindings and audit fields available. - 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 addAuditColumns(pool, 'screens'); - - // Current released schema: keep RSS feed interval and audit fields available. - await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL'); - await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL'); - await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60'); - await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'"); - await addColumnIfMissing(pool, 'rss_feeds', 'item_limit', 'INT NOT NULL DEFAULT 1'); - await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await addAuditColumns(pool, 'rss_feeds'); - - // Current released schema: keep normalized RSS feed item snapshots available. - await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL'); - await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL'); - await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL'); - await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await backfillLegacyRssFeedItemJson(pool); - - // Current released schema: keep API source polling and snapshot fields available. - await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL'); - await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL'); - await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60'); - await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'"); - await addColumnIfMissing(pool, 'api_sources', 'last_pulled_at', 'TIMESTAMP NULL'); - await addColumnIfMissing(pool, 'api_sources', 'last_pull_error', 'MEDIUMTEXT NULL'); - await addColumnIfMissing(pool, 'api_sources', 'last_response_status', 'INT NULL'); - await addColumnIfMissing(pool, 'api_sources', 'last_response_content_type', 'VARCHAR(255) NULL'); - await addColumnIfMissing(pool, 'api_sources', 'last_response_json', 'MEDIUMTEXT NULL'); - await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await addAuditColumns(pool, 'api_sources'); - - // Current released schema: keep onboarding device and RBAC audit fields available. - 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 addAuditColumns(pool, 'player_onboarding_devices'); - - 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 addAuditColumns(pool, 'users'); - - 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 addAuditColumns(pool, 'roles'); - - 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 addAuditColumns(pool, 'permissions'); - await dedupePermissionRows(pool); - await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key'); - - await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await addAuditColumns(pool, 'role_permissions'); - - await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); - await addAuditColumns(pool, 'user_roles'); - - await addAuditColumns(pool, 'auth_sessions'); - } - }, - { - key: 'playlist-skip-unavailable-rtmp', - version: appVersion, - comment: 'Add the playlist flag that skips RTMP slides when streams are unavailable.', - order: 21, - up: async function (pool) { - await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0'); - } - }, - { - key: 'slides-thumbnail-path-column', - version: appVersion, - comment: 'Backfill the slides.thumbnail_path column for databases that already recorded the broader schema migration.', - order: 22, - up: async function (pool) { - await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL'); - } - }, - { - key: 'playlist-slide-use-video-duration-flag', - version: appVersion, - comment: 'Add the playlist slide flag that follows the current video duration.', - order: 23, - up: async function (pool) { - await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0'); - } - }, - { - key: 'playlist-slide-duration-decimals', - version: appVersion, - comment: 'Widen playlist slide durations to preserve fractional seconds.', - order: 24, - up: async function (pool) { - await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000'); - } - }, - { - key: 'media-path-prefix-rename', - version: appVersion, - comment: 'Rename stored media URLs from /uploads to /media so existing slides and templates keep working after the storage root move.', - order: 25, - up: async function (pool) { - await backfillLegacyMediaPaths(pool); - } - }, - { - key: 'media-upload-subfolder-move', - version: appVersion, - comment: 'Move existing upload files and references from the media root into media/uploads.', - order: 26, - up: async function (pool, options) { - await backfillLegacyMediaUploadsToSubfolder(pool, options && options.mediaDir); - } - }, - { - key: 'media-upload-subfolder-rescue', - version: appVersion, - comment: 'Rescan loose media files and promote them into media/uploads.', - order: 27, - up: async function (pool, options) { - await backfillLooseMediaFilesToUploadsSubfolder(pool, options && options.mediaDir); - } - }, - { - key: 'background-tasks-table', - version: appVersion, - comment: 'Persist background tasks so queued work survives a web restart.', - order: 30, - up: async function (pool) { - await pool.query(` - CREATE TABLE IF NOT EXISTS background_tasks ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - task_key VARCHAR(191) NULL, - task_type VARCHAR(100) NOT NULL, - title VARCHAR(255) NOT NULL, - category VARCHAR(100) NOT NULL DEFAULT 'general', - status VARCHAR(20) NOT NULL, - payload_json MEDIUMTEXT NULL, - metadata_json MEDIUMTEXT NULL, - attempts INT NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - started_at TIMESTAMP NULL, - finished_at TIMESTAMP NULL, - error_message MEDIUMTEXT NULL, - INDEX idx_background_tasks_status (status), - INDEX idx_background_tasks_key (task_key), - INDEX idx_background_tasks_type (task_type) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `); - } - } -]; - -async function runMigrations(pool, options) { - const appliedRows = await getAppliedMigrationRows(pool); - const appliedKeys = new Set((appliedRows || []).map(function (row) { - return String(row.migration_key || '').trim(); - }).filter(Boolean)); - - const pendingMigrations = migrations - .filter(function (migration) { - return compareVersions(migration.version, appVersion) <= 0 - && !appliedKeys.has(migration.key); - }) - .sort(function (left, right) { - const versionOrder = compareVersions(left.version, right.version); - if (versionOrder !== 0) { - return versionOrder; - } - return Number(left.order || 0) - Number(right.order || 0); - }); - - for (const migration of pendingMigrations) { - await migration.up(pool, options || {}); - await recordMigration(pool, migration); - } +async function runMigrations(_pool, _options) { + return; } module.exports = { appVersion: appVersion, - compareVersions: compareVersions, runMigrations: runMigrations -}; \ No newline at end of file +}; diff --git a/src/player.js b/src/player.js index e181da5..e2fb655 100644 --- a/src/player.js +++ b/src/player.js @@ -9,14 +9,13 @@ const { createRtmpStreamService } = require('./player/modules/rtmp-streams'); const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding'); const { createOnboardingStore } = require('./player/onboarding/store'); const { registerPlayerRoutes } = require('./player/routes'); -const { pruneStaleOnboardingDevices } = require('./db'); // Player runtime, media API, and websocket wiring. async function start() { const app = express(); const pool = common.createPool(); - const PORT = Number(process.env.PLAYER_PORT || 3001); + const PORT = Number(process.env.PLAYER_PORT || 8081); const ASSET_DIR = path.join(__dirname, 'player', 'public'); const MEDIA_DIR = path.join(__dirname, '..', 'media'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); @@ -68,9 +67,10 @@ async function start() { async function syncDatabaseState() { try { await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); + await common.bootstrapDatabase(pool); if (playerRuntime.snapshotAllConnections().length > 0) { - await pruneStaleOnboardingDevices(pool); + await common.pruneStaleOnboardingDevices(pool); } await onboardingStore.flushBindings(function (entry) { diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index 7de6600..7a49995 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -74,8 +74,8 @@ async function getOnboardingStatus(pool, deviceId) { const [rows] = await pool.query( `SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id - FROM player_onboarding_devices d - LEFT JOIN screens s ON s.id = d.screen_id + FROM d_onboarding_devices d + LEFT JOIN d_screens s ON s.id = d.screen_id WHERE d.device_id = ?`, [normalizedDeviceId] ); @@ -99,7 +99,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam } return withClientNameReservation(pool, normalizedClientName, async function () { - const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]); + const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [normalizedScreenSlug]); if (!screenRows.length) { throw new Error('Screen not found.'); } @@ -113,7 +113,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam } await pool.query( - 'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', + 'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', [normalizedDeviceId, normalizedClientName, screen.id] ); @@ -218,7 +218,7 @@ function registerPlayerOnboardingRoutes(app, options) { app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { try { - const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC'); + const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC'); res.json({ screens: rows }); } catch (error) { next(error); diff --git a/src/player/player-client-name.script.html b/src/player/player-client-name.script.html index b69a147..94b9e34 100644 --- a/src/player/player-client-name.script.html +++ b/src/player/player-client-name.script.html @@ -58,7 +58,7 @@ // ignore storage errors } if (socket && socket.readyState === WebSocket.OPEN) { - sendCommandHello(socket); + sendCommandState(socket); } } diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index 5c7529f..4253eac 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -72,13 +72,13 @@ } // Announce the player to the command websocket. - function sendCommandHello(socket) { + function sendPlayerBootstrapState(socket) { if (!socket || socket.readyState !== WebSocket.OPEN) { return; } var clientName = getOnboardingClientName(); socket.send(JSON.stringify({ - type: 'hello', + type: 'state', clientId: getCommandClientId(), clientName: clientName || null, deviceId: getOnboardingDeviceId() || null, diff --git a/src/player/playlist.js b/src/player/playlist.js index 4fbfee7..9bf6a1b 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -56,7 +56,7 @@ function createPlayerPlaylistService(options) { async function buildScreenPlaylist(slug) { try { - const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]); + const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]); if (!screenRows.length) { return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] }; } @@ -73,16 +73,16 @@ function createPlayerPlaylistService(options) { return payloadWithoutPlaylist; } - const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]); + const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [screen.playlist_id]); const playlist = playlistRows[0] || null; const [slideRows] = await pool.query(` - SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at, + SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at, ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height - FROM playlist_slides ps - JOIN slides sl ON sl.id = ps.slide_id - LEFT JOIN slide_templates st ON st.id = sl.template_id - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + 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 ps.playlist_id = ? ORDER BY ps.position ASC, ps.id ASC `, [screen.playlist_id]); @@ -97,11 +97,11 @@ function createPlayerPlaylistService(options) { [templateRows] = await pool.query(` SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height - FROM slide_templates st - LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id + FROM c_templates st + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id WHERE st.id IN (?) `, [templateIds]); - [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); + [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); templateRows.forEach(function (template) { template.regions = regionRows.filter(function (region) { return region.template_id === template.id; }); templatesById[template.id] = template; @@ -144,7 +144,6 @@ function createPlayerPlaylistService(options) { return { id: slide.id, title: slide.title, - body: slide.body, modified_at: slide.modified_at, duration_seconds: videoDuration || storedDuration, use_video_duration: Boolean(slide.use_video_duration), @@ -154,9 +153,6 @@ function createPlayerPlaylistService(options) { schedule_start_time: slide.schedule_start_time, schedule_end_time: slide.schedule_end_time, schedule_days_json: slide.schedule_days_json, - media_url: slide.media_path, - media_type: slide.media_type, - kind: common.mediaKind(slide.media_path), template_id: slide.template_id, template: slide.template_id ? templatesById[slide.template_id] || null : null, content: content @@ -219,11 +215,8 @@ function createPlayerPlaylistService(options) { (Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) { updatePlaylistRevisionHash(hash, slide.id); updatePlaylistRevisionHash(hash, slide.title); - updatePlaylistRevisionHash(hash, slide.body); updatePlaylistRevisionHash(hash, slide.template_id); updatePlaylistRevisionHash(hash, slide.content_json); - updatePlaylistRevisionHash(hash, slide.media_path); - updatePlaylistRevisionHash(hash, slide.media_type); updatePlaylistRevisionHash(hash, slide.modified_at); updatePlaylistRevisionHash(hash, slide.position); updatePlaylistRevisionHash(hash, slide.duration_seconds); diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js index 6862dcf..f4b262f 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -381,11 +381,9 @@ function handleCommandMessage(rawMessage) { } return; case 'previous': - case 'left': navigateSlides(-1); return; case 'next': - case 'right': navigateSlides(1); return; case 'reload': @@ -424,11 +422,11 @@ function connectCommandSocket() { socket.onopen = function () { if (typeof syncOnboardingClientNameFromServer === 'function') { syncOnboardingClientNameFromServer(socket).then(function () { - sendCommandHello(socket); + sendCommandState(socket); }); return; } - sendCommandHello(socket); + sendCommandState(socket); }; socket.onmessage = function (event) { diff --git a/src/player/regions/api.js b/src/player/regions/api.js index 600120a..6b4545a 100644 --- a/src/player/regions/api.js +++ b/src/player/regions/api.js @@ -61,7 +61,7 @@ function substituteApiVariables(html, item) { function getApiPreviewFallback(item) { if (!item || typeof item !== 'object') { - return '